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;