diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index d0c5c98b8..ecad4e883 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -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. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index d29de342b..760803319 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -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, diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index 3d5db0392..813538c83 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -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), }; diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js index c77c9ca8c..8764ad49a 100644 --- a/.github/extensions/external-plugin-review-board/public/app.js +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -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; diff --git a/.github/extensions/external-plugin-review-board/public/index.html b/.github/extensions/external-plugin-review-board/public/index.html index 2e9de2197..6528f9b03 100644 --- a/.github/extensions/external-plugin-review-board/public/index.html +++ b/.github/extensions/external-plugin-review-board/public/index.html @@ -19,9 +19,10 @@ - + +
@@ -42,6 +43,7 @@ Open on GitHub ↗ +