mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-15 16:21:51 +00:00
fix(plugin-board): clarify intake-blocked review eligibility
Keep blocked submissions visible with intake reasons and accurate review controls. Expose eligibility through agent actions and cover blocked-to-ready transitions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1313d2be-6ae2-478e-952a-4b22a5e2d7cb
This commit is contained in:
co-authored by
Copilot App
parent
7e92bc5c8c
commit
6474ff2e8b
@@ -9,9 +9,10 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
|
||||
|
||||
- **Kanban board** with buckets: Unreviewed → Reviewing → Straight reject / Probably reject / Needs review /
|
||||
Accept → Actioned. Drag cards between buckets (or use the *Bucket* dropdown in the details panel).
|
||||
Dragging a card back to *Unreviewed* clears its AI review so it's picked up by the next review.
|
||||
Dragging a card back to *Unreviewed* clears its AI review so it's picked up by the next review
|
||||
once intake is ready.
|
||||
Cards in each bucket are sorted oldest to newest by creation date.
|
||||
- **Perform review** asks the agent to review every item without an AI review, using
|
||||
- **Perform review** asks the agent to review every intake-ready item without an AI review, using
|
||||
[`review-guidance.md`](./review-guidance.md) and the decisions you've made from the board. Results are
|
||||
recorded with the `record_review` canvas action and move cards into their recommended bucket.
|
||||
- **Re-review** (in the details panel) takes optional guidance on what to focus on, then has the agent
|
||||
@@ -21,6 +22,15 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
|
||||
`requires-submitter-fixes` states. Pending intake cards stay on the board so their AI reviews,
|
||||
manual buckets, queued re-reviews, and fast-track dismissal state are preserved. Approval and
|
||||
first-party fast-track are unavailable until intake is ready.
|
||||
- **Blocked intake status** keeps submissions visible in their existing buckets with a
|
||||
*Blocked by intake* badge and a reason (submitter fixes required, automated intake pending, or
|
||||
missing `ready-for-review`). The toolbar shows how many submissions can actually be queued and
|
||||
how many are blocked, regardless of search. AI review, re-review, and approval are unavailable
|
||||
until `ready-for-review` is present and both pending intake labels are absent; rejection and
|
||||
manual bucket moves remain available. The details panel explains the block on every tab.
|
||||
Check intake feedback on the issue and refresh after intake completes; moving a card does not
|
||||
bypass intake. `get_board` exposes eligibility and reasons, and an empty `start_review` result
|
||||
explains when submissions are blocked rather than implying that visible cards were reviewed.
|
||||
- **First-party fast-track**: cards labelled `first party` (repository owned by a Microsoft/GitHub
|
||||
organization) skip AI review and go straight to *Accept* with a suggested `/approve`. Only submissions
|
||||
that passed automated intake can use fast-track or be approved; pending submissions remain visible on the board.
|
||||
@@ -65,3 +75,5 @@ to check both canvases' session-scoped persistence without accessing GitHub.
|
||||
Run `node --test eng/board-bulk-actions.test.mjs` to check column confirmation, staged feedback,
|
||||
queue exclusions, and partial failures using in-process fakes.
|
||||
Run `node --test eng/board-preview.test.mjs` to check preview dismissal and in-flight actions.
|
||||
Run `node --test eng/external-plugin-review-board-state.test.mjs eng/external-plugin-review-board-ui.test.mjs`
|
||||
to check intake-blocked reasons, eligibility transitions, visible status, and unavailable controls.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
|
||||
import { BoardStore, isReadyForApproval, RECOMMENDATIONS } from "./lib/state.mjs";
|
||||
import { BoardStore, isReadyForApproval, reviewBlockedReason, RECOMMENDATIONS } from "./lib/state.mjs";
|
||||
import * as gh from "./lib/github.mjs";
|
||||
import { buildReviewPrompt, buildRereviewPrompt, REVIEW_FIELDS_SCHEMA } from "./lib/review-prompt.mjs";
|
||||
import { startBoardServer, expectedError } from "./lib/server.mjs";
|
||||
@@ -70,7 +70,20 @@ async function getIssue(number, { force = false } = {}) {
|
||||
|
||||
async function startReview({ numbers, instanceId, send }) {
|
||||
const items = store.pendingReview(numbers);
|
||||
if (!items.length) return { queued: [], message: "Nothing to review — no eligible unreviewed items remain." };
|
||||
if (!items.length) {
|
||||
const wanted = numbers?.length ? new Set(numbers.map(Number)) : null;
|
||||
const blocked = store.snapshot().items.filter((item) =>
|
||||
!item.decision && !item.pendingDecision && !item.reviewEligible &&
|
||||
item.reviewStatus !== "queued" &&
|
||||
(wanted ? wanted.has(item.number) : item.reviewStatus !== "reviewed"),
|
||||
).length;
|
||||
return {
|
||||
queued: [],
|
||||
message: blocked
|
||||
? `Nothing to review — ${blocked} submission(s) blocked by intake. AI review requires ready-for-review with no pending intake labels. Check the intake feedback on the issues and refresh after intake completes.`
|
||||
: "Nothing to review — no eligible unreviewed items remain.",
|
||||
};
|
||||
}
|
||||
const queued = items.map((item) => item.number);
|
||||
const snapshots = await store.markQueued(queued);
|
||||
const prompt = buildReviewPrompt({
|
||||
@@ -100,7 +113,7 @@ async function startRereview({ number, guidance, instanceId, send }) {
|
||||
if (item.pendingDecision) throw new Error(`#${item.number} has a posted command awaiting workflow confirmation.`);
|
||||
if (item.reviewStatus === "queued") throw new Error(`#${item.number} is already queued for review.`);
|
||||
if (!isReadyForApproval(item.labels ?? [])) {
|
||||
throw new Error(`#${item.number} is pending intake or is no longer ready-for-review.`);
|
||||
throw new Error(`#${item.number}: ${reviewBlockedReason(item.labels)}`);
|
||||
}
|
||||
const text = String(guidance ?? "").trim().slice(0, MAX_GUIDANCE_LENGTH);
|
||||
const snapshots = await store.markQueued([item.number], { rereviewGuidance: text || null });
|
||||
@@ -174,6 +187,7 @@ function boardSummary() {
|
||||
return {
|
||||
repo: snapshot.repo,
|
||||
lastRefreshedAt: snapshot.lastRefreshedAt,
|
||||
pendingReviewCount: store.pendingReview().length,
|
||||
counts: Object.fromEntries(
|
||||
snapshot.columns.map((column) => [column.id, snapshot.items.filter((i) => i.column === column.id).length]),
|
||||
),
|
||||
@@ -182,6 +196,8 @@ function boardSummary() {
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
column: item.column,
|
||||
reviewEligible: item.reviewEligible,
|
||||
reviewBlockedReason: item.reviewBlockedReason,
|
||||
aiRecommendation: item.review?.recommendation ?? null,
|
||||
manualOverride: item.manualColumn ?? null,
|
||||
suggestedComment: item.review?.suggestedComment ?? null,
|
||||
@@ -249,7 +265,7 @@ session = await joinSession({
|
||||
actions: [
|
||||
{
|
||||
name: "get_board",
|
||||
description: "Return every board item with its column, AI recommendation, suggested comment, and decision.",
|
||||
description: "Return every board item with its review eligibility, blocked reason, column, AI recommendation, suggested comment, and decision.",
|
||||
handler: wrap(() => boardSummary()),
|
||||
},
|
||||
{
|
||||
@@ -260,7 +276,7 @@ session = await joinSession({
|
||||
{
|
||||
name: "start_review",
|
||||
description:
|
||||
"Queue items for AI review (defaults to every item without a review) and return review instructions to follow.",
|
||||
"Queue intake-ready items for AI review (defaults to eligible items without a review) and return review instructions to follow. Pending intake submissions cannot be forced into review.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -20,7 +20,20 @@ const MANUAL_INTAKE_OVERRIDE_LABEL = "intake-manually-overridden";
|
||||
const PENDING_INTAKE_LABELS = ["awaiting-review", "requires-submitter-fixes"];
|
||||
|
||||
export function isReadyForApproval(labels) {
|
||||
return labels.includes("ready-for-review") && !labels.some((label) => PENDING_INTAKE_LABELS.includes(label));
|
||||
return reviewBlockedReason(labels) === null;
|
||||
}
|
||||
|
||||
export function reviewBlockedReason(labels = []) {
|
||||
if (labels.includes("requires-submitter-fixes")) {
|
||||
return "Submitter fixes required. Check the intake feedback on the issue; AI review and approval are unavailable until intake marks it ready-for-review.";
|
||||
}
|
||||
if (labels.includes("awaiting-review")) {
|
||||
return "Automated intake is pending. AI review and approval are unavailable until intake marks it ready-for-review.";
|
||||
}
|
||||
if (!labels.includes("ready-for-review")) {
|
||||
return "Missing ready-for-review status. AI review and approval are unavailable until intake marks it ready-for-review.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validated submissions from first-party organizations skip AI triage and land in Accept. Only
|
||||
@@ -146,6 +159,7 @@ export class BoardStore {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
reviewEligible: isReadyForApproval(item.labels ?? []),
|
||||
reviewBlockedReason: reviewBlockedReason(item.labels),
|
||||
})),
|
||||
history: this.state.history.slice(0, 50),
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const ui = {
|
||||
search: document.getElementById("search"),
|
||||
refresh: document.getElementById("refresh"),
|
||||
review: document.getElementById("review"),
|
||||
reviewStatus: document.getElementById("review-status"),
|
||||
drawer: document.getElementById("drawer"),
|
||||
drawerKicker: document.getElementById("drawer-kicker"),
|
||||
drawerTitle: document.getElementById("drawer-title"),
|
||||
@@ -20,6 +21,7 @@ const ui = {
|
||||
drawerClose: document.getElementById("drawer-close"),
|
||||
drawerMove: document.getElementById("drawer-move"),
|
||||
drawerRereview: document.getElementById("drawer-rereview"),
|
||||
drawerBlocked: document.getElementById("drawer-blocked"),
|
||||
rereviewPanel: document.getElementById("rereview-panel"),
|
||||
rereviewGuidance: document.getElementById("rereview-guidance"),
|
||||
rereviewCancel: document.getElementById("rereview-cancel"),
|
||||
@@ -110,6 +112,9 @@ function itemName(item) {
|
||||
|
||||
function signalPills(item) {
|
||||
const pills = [];
|
||||
if (!item.reviewEligible && !item.decision) {
|
||||
pills.push(h("span", { class: "pill pill-blocked", title: item.reviewBlockedReason }, "Blocked by intake"));
|
||||
}
|
||||
for (const label of item.labels ?? []) {
|
||||
const match = label.match(/^needs-review:(HIGH|MEDIUM|LOW)$/i);
|
||||
if (match) {
|
||||
@@ -173,6 +178,11 @@ function renderBoard() {
|
||||
const pending = pendingReviewCount();
|
||||
ui.review.textContent = pending ? `Perform review (${pending})` : "Perform review";
|
||||
ui.review.disabled = pending === 0;
|
||||
const blocked = board.items.filter((item) => !item.decision && !item.reviewEligible).length;
|
||||
ui.reviewStatus.textContent = `${pending ? `${pending} submission(s) ready to queue for AI review.` : "No submissions can be queued for AI review."}${
|
||||
blocked ? ` ${blocked} blocked by intake; check the issue feedback and refresh after intake completes.` : ""
|
||||
}`;
|
||||
ui.review.title = ui.reviewStatus.textContent;
|
||||
|
||||
const scrollPositions = new Map(
|
||||
[...ui.board.querySelectorAll(".column")].map((col) => [col.dataset.column, col.querySelector(".column-cards").scrollTop]),
|
||||
@@ -221,7 +231,11 @@ function renderColumnActions(column) {
|
||||
const count = columnItems(column.id, kind).length;
|
||||
return h("button", {
|
||||
type: "button", class: "btn btn-small", disabled: bulkBusy || !count,
|
||||
title: `Apply to all ${count} eligible items in this column, including hidden search results`,
|
||||
title: count
|
||||
? `Apply to all ${count} eligible items in this column, including hidden search results`
|
||||
: kind === "reject"
|
||||
? "No items available to reject in this column"
|
||||
: "No eligible items in this column; AI review and approval require completed intake",
|
||||
onclick: () => bulkAction(column.id, kind),
|
||||
}, `${kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"} all`);
|
||||
}));
|
||||
@@ -299,6 +313,9 @@ function renderCard(item) {
|
||||
},
|
||||
h("div", { class: "card-top" }, h("span", { class: "card-number" }, `#${item.number}`), h("span", { class: "card-byline" }, `@${item.author} · ${relativeTime(item.createdAt)}`)),
|
||||
h("p", { class: "card-title" }, itemName(item)),
|
||||
!item.reviewEligible && !item.decision
|
||||
? h("p", { class: "card-blocked" }, item.reviewBlockedReason)
|
||||
: null,
|
||||
item.review?.rationale ? h("p", { class: "card-rationale" }, item.review.rationale) : null,
|
||||
);
|
||||
const pills = signalPills(item);
|
||||
@@ -347,7 +364,9 @@ async function moveItem(number, column) {
|
||||
try {
|
||||
await api("POST", "/api/move", { number, column });
|
||||
if (selected === number) closeDrawer();
|
||||
if (hadReview) toast(`Cleared the AI review for #${number}; it will be included in the next review.`);
|
||||
if (hadReview) toast(findItem(number)?.reviewEligible
|
||||
? `Cleared the AI review for #${number}; it will be included in the next review.`
|
||||
: `Cleared the AI review for #${number}; intake must complete before it can be reviewed again.`);
|
||||
} catch (error) {
|
||||
toast(error.message, { error: true });
|
||||
renderBoard();
|
||||
@@ -404,6 +423,10 @@ function renderDrawer() {
|
||||
ui.drawerMove.disabled = Boolean(item.decision);
|
||||
ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) ||
|
||||
Boolean(item.pendingDecision) || item.reviewStatus === "queued";
|
||||
ui.drawerBlocked.hidden = item.reviewEligible || Boolean(item.decision);
|
||||
ui.drawerBlocked.textContent = ui.drawerBlocked.hidden ? "" : `Blocked by intake: ${item.reviewBlockedReason}`;
|
||||
ui.drawerRereview.title = !item.reviewEligible ? item.reviewBlockedReason : "";
|
||||
ui.approve.title = !item.reviewEligible ? item.reviewBlockedReason : "";
|
||||
if (ui.drawerRereview.disabled) hideRereview();
|
||||
|
||||
for (const tab of ui.tabs) {
|
||||
@@ -483,7 +506,9 @@ function renderReview(item) {
|
||||
{ class: "notice" },
|
||||
item.reviewStatus === "queued"
|
||||
? `Queued for AI review ${relativeTime(item.queuedAt)}. Results appear here when the agent records them.`
|
||||
: "No AI review yet. Use “Perform review” or “Re-review” to have the agent assess it.",
|
||||
: !item.reviewEligible
|
||||
? "No AI review yet. Review is blocked until intake completes."
|
||||
: "No AI review yet. Use “Perform review” or “Re-review” to have the agent assess it.",
|
||||
),
|
||||
);
|
||||
return wrapper;
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
<input id="search" type="search" placeholder="Filter by #, title, author…" autocomplete="off" />
|
||||
</label>
|
||||
<button id="refresh" type="button" class="btn">Refresh</button>
|
||||
<button id="review" type="button" class="btn btn-primary">Perform review</button>
|
||||
<button id="review" type="button" class="btn btn-primary" aria-describedby="review-status" disabled>Perform review</button>
|
||||
</div>
|
||||
</header>
|
||||
<p id="review-status" class="review-status meta" role="status">Loading review eligibility...</p>
|
||||
|
||||
<main id="board" class="board" aria-label="Submission board"></main>
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
<button type="button" class="btn btn-small" id="drawer-rereview">Re-review</button>
|
||||
<a class="btn btn-small" id="drawer-link" target="_blank" rel="noopener noreferrer">Open on GitHub ↗</a>
|
||||
</div>
|
||||
<p id="drawer-blocked" class="blocked-notice" hidden></p>
|
||||
<form class="rereview-panel" id="rereview-panel" hidden>
|
||||
<label for="rereview-guidance" class="footer-label">What should the re-review focus on? (optional)</label>
|
||||
<textarea
|
||||
|
||||
@@ -173,6 +173,30 @@ h2 {
|
||||
}
|
||||
|
||||
/* Board */
|
||||
.review-status {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
|
||||
.blocked-notice,
|
||||
.card-blocked {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--orange-muted);
|
||||
font-size: var(--text-body-small, 12px);
|
||||
}
|
||||
|
||||
.drawer > .blocked-notice {
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.pill-blocked {
|
||||
border-color: var(--orange);
|
||||
color: var(--orange);
|
||||
background: var(--orange-muted);
|
||||
}
|
||||
|
||||
.board {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
@@ -20,7 +20,7 @@ for (const boardName of ["open-pr-board", "external-plugin-review-board"]) {
|
||||
const rendered = [];
|
||||
const node = { querySelectorAll: () => [], replaceChildren() {} };
|
||||
const ui = {
|
||||
board: node, title: {}, summary: {}, meta: {}, review: {},
|
||||
board: node, title: {}, summary: {}, meta: {}, review: {}, reviewStatus: {},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
board: { items, columns: [{ id: "approve" }, { id: "close" }, { id: "empty" }] },
|
||||
|
||||
@@ -106,6 +106,27 @@ async function writeJSON(file, value) {
|
||||
await writeFile(file, JSON.stringify(value), "utf8");
|
||||
}
|
||||
|
||||
test("external-plugin-review-board agent actions explain nine blocked unreviewed submissions without queuing", async (t) => {
|
||||
const f = await fixture(t, "external-plugin-review-board");
|
||||
await writeJSON(f.stateFile, {
|
||||
version: 1, repo, items: Object.fromEntries(Array.from({ length: 9 }, (_, index) => [
|
||||
index + 1, { ...item, number: index + 1, labels: ["external-plugin", "requires-submitter-fixes"], reviewStatus: "unreviewed" },
|
||||
])), history: [],
|
||||
});
|
||||
const act = await loadCanvas(f);
|
||||
const snapshot = await act("get_board");
|
||||
assert.equal(snapshot.counts.unreviewed, 9);
|
||||
assert.equal(snapshot.pendingReviewCount, 0);
|
||||
assert.ok(snapshot.items.every((entry) => !entry.reviewEligible && /Submitter fixes required/.test(entry.reviewBlockedReason)));
|
||||
const result = await act("start_review");
|
||||
assert.equal(result.queued.length, 0);
|
||||
assert.match(result.message, /9 submission\(s\) blocked by intake/);
|
||||
assert.match((await act("start_review", { numbers: [1] })).message, /1 submission\(s\) blocked by intake/);
|
||||
await assert.rejects(act("start_rereview", { number: 1 }), /Submitter fixes required/);
|
||||
const saved = JSON.parse(await readFile(f.stateFile, "utf8"));
|
||||
assert.ok(Object.values(saved.items).every((entry) => entry.reviewStatus === "unreviewed" && !entry.queueId));
|
||||
});
|
||||
|
||||
for (const id of ["open-pr-board", "external-plugin-review-board"]) {
|
||||
const column = id === "open-pr-board" ? "needs-insight" : "needs-review";
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BoardStore,
|
||||
effectiveColumn,
|
||||
isReadyForApproval,
|
||||
reviewBlockedReason,
|
||||
} from "../.github/extensions/external-plugin-review-board/lib/state.mjs";
|
||||
|
||||
function issue(number, labels = []) {
|
||||
@@ -103,6 +104,21 @@ test("approval requires ready status and no pending intake label", () => {
|
||||
assert.equal(isReadyForApproval(["external-plugin", "rejected"]), false);
|
||||
});
|
||||
|
||||
test("blocked reasons match eligibility, including conflicting labels and missing labels", () => {
|
||||
for (const [labels, expected] of [
|
||||
[[], /Missing ready-for-review/],
|
||||
[["awaiting-review"], /Automated intake is pending/],
|
||||
[["requires-submitter-fixes"], /Submitter fixes required/],
|
||||
[["ready-for-review", "awaiting-review"], /Automated intake is pending/],
|
||||
[["ready-for-review", "awaiting-review", "requires-submitter-fixes"], /Submitter fixes required/],
|
||||
]) {
|
||||
assert.equal(isReadyForApproval(labels), false);
|
||||
assert.match(reviewBlockedReason(labels), expected);
|
||||
}
|
||||
assert.equal(reviewBlockedReason(["ready-for-review"]), null);
|
||||
assert.match(reviewBlockedReason(), /Missing ready-for-review/);
|
||||
});
|
||||
|
||||
test("pending intake cards stay visible but become review-eligible only when ready", () => {
|
||||
const store = new BoardStore("unused", "github/awesome-copilot");
|
||||
const existingReview = { recommendation: "needs-review", rationale: "Keep this review." };
|
||||
@@ -133,6 +149,8 @@ test("pending intake cards stay visible but become review-eligible only when rea
|
||||
);
|
||||
assert.deepEqual(store.pendingReview().map((item) => item.number), [3]);
|
||||
assert.deepEqual(store.pendingReview([1, 2, 3, 4]).map((item) => item.number), [3]);
|
||||
assert.match(pendingSnapshot.items.find((item) => item.number === 2).reviewBlockedReason, /Submitter fixes required/);
|
||||
assert.equal(pendingSnapshot.items.find((item) => item.number === 3).reviewBlockedReason, null);
|
||||
|
||||
store.getItem(1).labels = ["external-plugin", "ready-for-review"];
|
||||
store.getItem(2).labels = ["external-plugin", "ready-for-review"];
|
||||
@@ -144,6 +162,7 @@ test("pending intake cards stay visible but become review-eligible only when rea
|
||||
assert.equal(store.getItem(4).review, queuedReview);
|
||||
assert.equal(store.getItem(4).reviewStatus, "queued");
|
||||
assert.equal(store.getItem(4).queueId, "queued-review");
|
||||
assert.ok(store.snapshot().items.every((item) => item.reviewBlockedReason === null));
|
||||
});
|
||||
|
||||
test("first-party submissions are not fast-tracked without a ready-for-review label", async (t) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
import { test } from "node:test";
|
||||
import { BoardStore, reviewBlockedReason } from "../.github/extensions/external-plugin-review-board/lib/state.mjs";
|
||||
|
||||
const appSource = await readFile(
|
||||
new URL("../.github/extensions/external-plugin-review-board/public/app.js", import.meta.url),
|
||||
@@ -24,6 +25,7 @@ function createContext(item, { bulkBusy = false } = {}) {
|
||||
drawerLink: {},
|
||||
drawerMove: { replaceChildren() {} },
|
||||
drawerRereview: {},
|
||||
drawerBlocked: {},
|
||||
tabs: [],
|
||||
drawerFooter: {},
|
||||
approve: button(),
|
||||
@@ -48,6 +50,19 @@ function createContext(item, { bulkBusy = false } = {}) {
|
||||
return { context, ui };
|
||||
}
|
||||
|
||||
function element(tag, attrs = {}, ...children) {
|
||||
return {
|
||||
tag, attrs, children: children.flat(),
|
||||
append(...more) { this.children.push(...more); },
|
||||
addEventListener() {},
|
||||
};
|
||||
}
|
||||
|
||||
function text(el) {
|
||||
if (el == null || el === false) return "";
|
||||
return typeof el === "object" ? el.children.map(text).join(" ") : String(el);
|
||||
}
|
||||
|
||||
function evaluateFunction(context, source, signature, nextSignature) {
|
||||
const start = source.indexOf(signature);
|
||||
const end = source.indexOf(nextSignature, start);
|
||||
@@ -96,3 +111,88 @@ test("Approve retains its existing eligible, pending-decision, and bulk-busy sta
|
||||
reset.context.resetConfirm();
|
||||
assert.equal(reset.ui.approve.disabled, true);
|
||||
});
|
||||
|
||||
for (const labels of [["requires-submitter-fixes"], ["awaiting-review"], []]) {
|
||||
test(`blocked cards and drawer explain intake status: ${labels.join(", ") || "missing ready label"}`, () => {
|
||||
const item = {
|
||||
number: 42, author: "contributor", labels, reviewEligible: false,
|
||||
reviewBlockedReason: reviewBlockedReason(labels), reviewStatus: "unreviewed",
|
||||
};
|
||||
const { context, ui } = createContext(item);
|
||||
context.h = element;
|
||||
evaluateFunction(context, appSource, "function signalPills(", "function matchesFilter(");
|
||||
evaluateFunction(context, appSource, "function renderCard(", "function wireDropTarget(");
|
||||
evaluateFunction(context, appSource, "function renderReview(", "function markdownBlock(");
|
||||
evaluateFunction(context, appSource, "function renderDrawer()", "function renderTab(");
|
||||
|
||||
const card = context.renderCard(item);
|
||||
assert.match(text(card), /Blocked by intake/);
|
||||
assert.ok(text(card).includes(item.reviewBlockedReason));
|
||||
assert.equal(card.attrs.draggable, "true");
|
||||
context.renderDrawer();
|
||||
assert.equal(ui.drawerBlocked.hidden, false);
|
||||
assert.ok(ui.drawerBlocked.textContent.includes(item.reviewBlockedReason));
|
||||
assert.equal(ui.drawerRereview.disabled, true);
|
||||
assert.equal(ui.approve.disabled, true);
|
||||
assert.equal(ui.reject.disabled, false);
|
||||
assert.equal(ui.drawerRereview.title, item.reviewBlockedReason);
|
||||
assert.match(text(context.renderReview(item)), /Review is blocked until intake completes/);
|
||||
assert.doesNotMatch(text(context.renderReview(item)), /Use .*Perform review/);
|
||||
|
||||
item.labels = ["ready-for-review"];
|
||||
item.reviewEligible = true;
|
||||
item.reviewBlockedReason = null;
|
||||
context.renderDrawer();
|
||||
assert.equal(ui.drawerBlocked.hidden, true);
|
||||
assert.equal(ui.drawerBlocked.textContent, "");
|
||||
assert.equal(ui.approve.disabled, false);
|
||||
assert.equal(ui.drawerRereview.disabled, false);
|
||||
assert.doesNotMatch(text(context.renderCard(item)), /Blocked by intake/);
|
||||
});
|
||||
}
|
||||
|
||||
test("toolbar shows zero queueable reviews for nine visible blocked submissions, including while searching", () => {
|
||||
const store = new BoardStore("unused", "github/awesome-copilot");
|
||||
store.state.items = Object.fromEntries(Array.from({ length: 9 }, (_, index) => [
|
||||
index + 1, { number: index + 1, labels: ["requires-submitter-fixes"], reviewStatus: "unreviewed" },
|
||||
]));
|
||||
const { context, ui } = createContext(store.getItem(1));
|
||||
context.board = store.snapshot();
|
||||
context.filter = "no matches";
|
||||
context.h = element;
|
||||
context.renderCard = element;
|
||||
context.renderColumnActions = element;
|
||||
context.matchesFilter = () => false;
|
||||
context.wireDropTarget = () => {};
|
||||
ui.board = { querySelectorAll: () => [], replaceChildren() {} };
|
||||
ui.meta = {};
|
||||
ui.review = {};
|
||||
ui.reviewStatus = {};
|
||||
evaluateFunction(context, appSource, "function pendingReviewCount()", "// ---------- board");
|
||||
evaluateFunction(context, appSource, "function renderBoard()", "function columnItems(");
|
||||
context.renderBoard();
|
||||
assert.equal(ui.review.disabled, true);
|
||||
assert.match(ui.reviewStatus.textContent, /No submissions can be queued/);
|
||||
assert.match(ui.reviewStatus.textContent, /9 blocked by intake/);
|
||||
|
||||
store.getItem(1).labels = ["ready-for-review"];
|
||||
context.board = store.snapshot();
|
||||
context.renderBoard();
|
||||
assert.equal(ui.review.disabled, false);
|
||||
assert.equal(ui.review.textContent, "Perform review (1)");
|
||||
assert.match(ui.reviewStatus.textContent, /1 submission\(s\) ready to queue/);
|
||||
assert.match(ui.reviewStatus.textContent, /8 blocked by intake/);
|
||||
});
|
||||
|
||||
test("blocked reviewed cards retain their review without suggesting an unavailable new review", () => {
|
||||
const item = {
|
||||
number: 42, labels: ["requires-submitter-fixes"], reviewEligible: false,
|
||||
reviewBlockedReason: reviewBlockedReason(["requires-submitter-fixes"]),
|
||||
reviewStatus: "reviewed", review: { recommendation: "needs-review", rationale: "Original review" },
|
||||
};
|
||||
const { context } = createContext(item);
|
||||
context.h = element;
|
||||
context.RECOMMENDATION_LABELS = { "needs-review": "Needs review" };
|
||||
evaluateFunction(context, appSource, "function renderReview(", "function markdownBlock(");
|
||||
assert.match(text(context.renderReview(item)), /Original review/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user