chore: publish from main

This commit is contained in:
github-actions[bot]
2026-10-15 03:27:01 +00:00
parent dc4243c2a4
commit 76738071cc
10 changed files with 246 additions and 13 deletions
@@ -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.some((label) => PENDING_INTAKE_LABELS.includes(label))) {
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;
+1 -1
View File
@@ -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" }] },
+21
View File
@@ -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/);
});