chore: merge origin/main into external plugin ownership

Preserve intake readiness checks alongside pending-decision safeguards.
Gate newly merged bulk approval and re-review actions on intake readiness.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 821f4796-505e-4fb8-96cf-0b5bbf80a636
This commit is contained in:
Aaron Powell
2026-10-13 14:09:36 +11:00
co-authored by Copilot App
15 changed files with 645 additions and 63 deletions
@@ -28,6 +28,11 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
- **Details panel** shows the AI review, the rendered issue body, and all comments.
- **Quick actions** post `/approve` (with an optional note) or `/reject <reason>` straight to the issue
after a confirm click. The reject reason is prefilled from the AI's suggested comment.
- **Column actions** approve, reject, or re-review every eligible item in a column, including
cards hidden by search. Confirmation previews each staged comment; rejection requires a reason
for every item. Drawer edits remain staged when switching cards in the current browser session.
Queued reviews, pending decisions, and completed actions are excluded. Failures are reported per
item without automatically retrying posted commands.
## State
@@ -53,3 +58,5 @@ not imported; boards start fresh.
Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` from the repository root
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.
@@ -23,6 +23,7 @@ const servers = new Map();
const detailCache = new Map();
let refreshInFlight = null;
let session;
const decisionsInFlight = new Set();
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -69,7 +70,7 @@ 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 — all items already have an AI review." };
if (!items.length) return { queued: [], message: "Nothing to review — no eligible unreviewed items remain." };
const queued = items.map((item) => item.number);
const snapshots = await store.markQueued(queued);
const prompt = buildReviewPrompt({
@@ -96,6 +97,7 @@ const MAX_GUIDANCE_LENGTH = 4000;
async function startRereview({ number, guidance, instanceId, send }) {
const item = store.getItem(Number(number));
if (item.decision) throw new Error(`#${item.number} was already actioned (${item.decision.kind}).`);
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.`);
@@ -131,9 +133,20 @@ function stripCommand(text, command) {
}
async function postDecision({ number, kind, comment }) {
if (decisionsInFlight.has(number)) throw new Error(`#${number} already has a GitHub action in progress.`);
decisionsInFlight.add(number);
try {
return await postDecisionCommand({ number, kind, comment });
} finally {
decisionsInFlight.delete(number);
}
}
async function postDecisionCommand({ number, kind, comment }) {
if (kind !== "approve" && kind !== "reject") throw new Error(`Unknown decision "${kind}".`);
const item = store.getItem(number);
if (item.decision) throw new Error(`#${number} was already actioned (${item.decision.kind}).`);
if (item.pendingDecision) throw new Error(`#${number} already has a posted command awaiting workflow confirmation. Refresh the board.`);
const status = await gh.getIssueStatus(store.state.repo, number);
if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`);
@@ -195,6 +195,7 @@ export class BoardStore {
(item) =>
isReadyForApproval(item.labels ?? []) &&
!item.decision &&
!item.pendingDecision &&
item.reviewStatus !== "queued",
);
if (numbers?.length) {
@@ -41,6 +41,8 @@ let selected = null;
let activeTab = "review";
const details = new Map();
const pendingConfirm = { kind: null, timer: null };
const stagedComments = new Map();
let bulkBusy = false;
// ---------- helpers ----------
@@ -155,6 +157,7 @@ function pendingReviewCount() {
(item) =>
item.reviewEligible &&
!item.decision &&
!item.pendingDecision &&
item.reviewStatus !== "reviewed" &&
item.reviewStatus !== "queued",
).length
@@ -186,6 +189,7 @@ function renderBoard() {
"section",
{ class: "column", dataset: { column: column.id }, "aria-label": column.title },
h("div", { class: "column-header" }, h("span", { class: "column-name" }, column.title), h("span", { class: "count" }, items.length)),
column.droppable ? renderColumnActions(column) : null,
cards,
);
if (column.droppable) wireDropTarget(col, column.id);
@@ -198,6 +202,80 @@ function renderBoard() {
}
}
function columnItems(columnId, kind) {
return board.items.filter((item) => item.column === columnId && !item.decision &&
!item.pendingDecision && item.reviewStatus !== "queued" &&
(kind === "reject" || item.reviewEligible));
}
function stagedComment(item) {
return (stagedComments.get(item.number) ?? defaultComment(item))
.trim().replace(/^\/(approve|reject)\b\s*/i, "").trim();
}
function renderColumnActions(column) {
return h("div", { class: "column-actions", role: "group", "aria-label": `${column.title} bulk actions` },
["approve", "reject", "rereview"].map((kind) => {
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`,
onclick: () => bulkAction(column.id, kind),
}, `${kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject"} all`);
}));
}
async function bulkAction(columnId, kind) {
if (bulkBusy) return;
const items = columnItems(columnId, kind).map((item) => ({
number: item.number, comment: stagedComment(item),
}));
if (!items.length) return;
if (kind === "reject") {
const missing = items.filter((item) => !item.comment.trim().replace(/^\/(approve|reject)\b\s*/i, "").trim());
if (missing.length) {
toast(`Stage a rejection reason for ${missing.map((item) => `#${item.number}`).join(", ")} before rejecting this column.`, { error: true });
return;
}
}
const preview = items.map((item) => `#${item.number}${kind === "rereview" ? "" : `: ${
kind === "approve" ? "/approve" : "/reject"} ${item.comment}`}`).join("\n\n");
const label = kind === "rereview" ? "Re-review" : kind === "approve" ? "Approve" : "Reject";
if (!confirm(`${label} all ${items.length} items in this column (including hidden search results)?\n${
kind === "rereview" ? "Queues AI reviews; GitHub is not changed." : "Posts each item's staged comment to GitHub."}\n\n${preview}`)) return;
bulkBusy = true;
renderBoard();
renderDrawer();
let completed = 0;
const failures = [];
try {
if (kind === "rereview") {
const result = await api("POST", "/api/review", { numbers: items.map((item) => item.number) });
toast(result.message);
return;
}
for (const item of items) {
try {
if (!columnItems(columnId, kind).some((current) => current.number === item.number)) {
throw new Error("Item moved, was queued, or was already actioned; review it again.");
}
await api("POST", "/api/decision", { ...item, kind });
completed++;
} catch (error) {
failures.push(`#${item.number}: ${error.message}`);
}
}
toast(`${label}: ${completed}/${items.length} commands posted.${failures.length ? ` Failed: ${failures.join(" ")}` : ""}`,
{ error: failures.length > 0 });
} catch (error) {
toast(error.message, { error: true });
} finally {
bulkBusy = false;
renderBoard();
renderDrawer();
}
}
function renderCard(item) {
const draggable = !item.decision;
const card = h(
@@ -279,7 +357,7 @@ function openDrawer(number) {
resetConfirm();
hideRereview();
const item = findItem(number);
ui.comment.value = defaultComment(item);
ui.comment.value = stagedComment(item);
ui.drawer.classList.add("open");
ui.drawer.setAttribute("aria-hidden", "false");
renderDrawer();
@@ -320,7 +398,8 @@ function renderDrawer() {
.map((column) => h("option", { value: column.id, selected: column.id === item.column }, column.title));
ui.drawerMove.replaceChildren(...options);
ui.drawerMove.disabled = Boolean(item.decision);
ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) || item.reviewStatus === "queued";
ui.drawerRereview.disabled = !item.reviewEligible || Boolean(item.decision) ||
Boolean(item.pendingDecision) || item.reviewStatus === "queued";
if (ui.drawerRereview.disabled) hideRereview();
for (const tab of ui.tabs) {
@@ -333,6 +412,8 @@ function renderDrawer() {
ui.drawerFooter.hidden = Boolean(item.decision);
updatePreview();
ui.approve.disabled = bulkBusy || Boolean(item.pendingDecision);
ui.reject.disabled ||= bulkBusy || Boolean(item.pendingDecision);
renderTab(item);
}
@@ -498,11 +579,16 @@ function resetConfirm() {
ui.approve.disabled = false;
ui.reject.disabled = false;
updatePreview();
const item = findItem(selected);
if (bulkBusy || item?.pendingDecision) {
ui.approve.disabled = true;
ui.reject.disabled = true;
}
}
async function decide(kind) {
const item = findItem(selected);
if (!item) return;
if (!item || bulkBusy || item.pendingDecision) return;
if (kind === "reject" && !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "")) {
toast("Reject decisions require a reason.", { error: true });
updatePreview();
@@ -641,6 +727,7 @@ for (const tab of ui.tabs) {
}
ui.comment.addEventListener("input", () => {
if (selected != null) stagedComments.set(selected, ui.comment.value);
if (pendingConfirm.kind) resetConfirm();
else updatePreview();
});
@@ -228,6 +228,13 @@ h2 {
gap: 8px;
}
.column-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px 8px;
}
.column-name::before {
content: "";
width: 8px;
+9 -1
View File
@@ -21,6 +21,12 @@ authenticated `gh` CLI.
PRs with submitted feedback or requested changes return to Unreviewed after new
commits; the previous action remains in history.
- **Re-review** — ask for a fresh pass on one PR with optional focus guidance.
- **Column actions** — approve + auto-merge, request changes (close in the Close column), or
re-review every eligible PR, including cards hidden by search. Confirmation previews the exact
staged comments; requests for changes and bulk closes require feedback on every PR.
Drawer edits remain staged when switching cards in the current browser session. Queued and
actioned PRs are excluded; individual errors and partial-success warnings are surfaced without
automatic retries.
- **Labels** — GitHub labels render on every card in their own colours, blended toward
the app theme so they stay readable in both light and dark mode.
- **Detail drawer** — AI review, description, conversation, changed files, and checks.
@@ -74,7 +80,7 @@ touching GitHub.
The agent reads [`review-guidance.md`](./review-guidance.md), which layers PR-specific
procedure on top of `.github/skills/code-review/SKILL.md` and
`.github/copilot-instructions.md`. AI evidence gathering is read-only: the agent never
edits files or runs write commands against the PR. Only an explicitly confirmed drawer
edits files or runs write commands against the PR. Only an explicitly confirmed drawer or column
action writes to GitHub.
Review results require the exact queue token from the current review request.
@@ -84,5 +90,7 @@ Results arriving after a manual move, reset, or replacement review are rejected.
Run `node --test eng/open-pr-board.test.mjs` from the repository root. GitHub writes
are tested with injected in-process fakes, never the authenticated CLI.
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 --experimental-vm-modules --test eng/board-state-storage.test.mjs` to check
both canvases' session-scoped persistence without accessing GitHub.
@@ -113,10 +113,10 @@ function pendingReview(store, numbers) {
const snapshot = store.snapshot();
if (Array.isArray(numbers) && numbers.length) {
const wanted = new Set(numbers.map(Number));
return snapshot.items.filter((item) => wanted.has(item.number) && !item.decision);
return snapshot.items.filter((item) => wanted.has(item.number) && !item.decision && item.reviewStatus !== "queued");
}
// Default pass: anything never reviewed, plus reviews invalidated by new commits.
return snapshot.items.filter((item) => !item.decision && (!item.review || item.reviewStale));
return snapshot.items.filter((item) => !item.decision && item.reviewStatus !== "queued" && (!item.review || item.reviewStale));
}
async function startReview({ store, numbers, instanceId, send }) {
@@ -187,14 +187,39 @@ const LIST_FIELDS = [
"additions",
"deletions",
"changedFiles",
"statusCheckRollup",
"reviewDecision",
"mergeStateStatus",
].join(",");
export async function listOpenPullRequests(repo) {
const out = await runGh(["pr", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", LIST_FIELDS]);
return JSON.parse(out).map(normalizePullRequest);
const CHECK_FETCH_CONCURRENCY = 6;
async function mapWithConcurrency(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const index = next++;
results[index] = await fn(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// statusCheckRollup is fetched per PR because requesting it in the bulk list query
// makes GitHub's GraphQL API time out (HTTP 504) on repos with many open PRs.
export async function listOpenPullRequests(repo, runner = runGh) {
const out = await runner(["pr", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", LIST_FIELDS]);
const pullRequests = JSON.parse(out);
await mapWithConcurrency(pullRequests, CHECK_FETCH_CONCURRENCY, async (pr) => {
try {
const raw = await runner(["pr", "view", String(pr.number), "-R", repo, "--json", "statusCheckRollup"]);
pr.statusCheckRollup = JSON.parse(raw).statusCheckRollup;
} catch {
pr.statusCheckRollup = [];
}
});
return pullRequests.map(normalizePullRequest);
}
const FULL_JSON = ["-H", "Accept: application/vnd.github.full+json"];
+91 -3
View File
@@ -42,6 +42,8 @@ let pendingConfirm = null;
let detailGeneration = 0;
let drawerOriginNumber = null;
const decisionsInFlight = new Set();
const stagedComments = new Map();
let bulkBusy = false;
const el = {
title: document.getElementById("board-title"),
@@ -315,6 +317,7 @@ function renderBoard() {
h("span", { class: "column-name" }, column.label),
h("span", { class: "count" }, String(items.length)),
),
column.droppable ? renderColumnActions(column) : null,
list,
);
});
@@ -339,6 +342,84 @@ function renderBoard() {
el.review.disabled = pending === 0;
}
function columnItems(columnId) {
return board.items.filter((item) => item.column === columnId && !item.decision &&
item.reviewStatus !== "queued" && !decisionsInFlight.has(item.number));
}
function stagedComment(item) {
return stagedComments.get(item.number) ?? item.review?.suggestedComment ?? "";
}
function renderColumnActions(column) {
const count = columnItems(column.id).length;
const rejectKind = column.id === "close" ? "close" : "request-changes";
const actions = [["approve", "Approve + auto-merge"], [rejectKind, DECISION_LABELS[rejectKind]], ["rereview", "Re-review"]];
return h("div", { class: "column-actions", role: "group", "aria-label": `${column.label} bulk actions` },
actions.map(([kind, label]) => h("button", {
type: "button", class: "btn btn-small", disabled: bulkBusy || !count,
title: `Apply to all ${count} eligible PRs in this column, including hidden search results`,
onclick: () => bulkAction(column.id, kind),
}, `${label} all`)));
}
async function bulkAction(columnId, kind) {
if (bulkBusy) return;
const items = columnItems(columnId).map((item) => ({
number: item.number, headRefOid: item.headRefOid, comment: stagedComment(item),
}));
if (!items.length) return;
if (kind === "close" || kind === "request-changes") {
const missing = items.filter((item) => !item.comment.trim());
if (missing.length) {
toast(`Stage feedback for ${missing.map((item) => `#${item.number}`).join(", ")} before actioning this column.`, { error: true });
return;
}
}
const label = kind === "rereview" ? "Re-review" : DECISION_LABELS[kind];
const preview = items.map((item) => `#${item.number}${kind === "rereview" ? "" : `: ${item.comment || "(no comment)"}`}`).join("\n\n");
if (!confirm(`${label} all ${items.length} PRs in this column (including hidden search results)?\n${
kind === "rereview" ? "Queues AI reviews; GitHub is not changed." : kind === "approve" ?
"Submits approvals and enables auto-merge with a merge commit." : "Uses each PR's staged comment on GitHub."}\n\n${preview}`)) return;
bulkBusy = true;
renderBoard();
let completed = 0;
const failures = [];
try {
if (kind === "rereview") {
const result = await api("/api/review", { method: "POST", body: { numbers: items.map((item) => item.number) } });
toast(result.message);
return;
}
for (const item of items) {
try {
const current = columnItems(columnId).find((current) => current.number === item.number);
if (!current || current.headRefOid !== item.headRefOid) {
throw new Error("PR moved, changed, or was already actioned; review it again.");
}
decisionsInFlight.add(item.number);
const result = await api("/api/decision", {
method: "POST", body: { number: item.number, kind, comment: item.comment },
});
completed++;
if (result.warning) failures.push(`#${item.number}: ${result.warning}`);
} catch (error) {
failures.push(`#${item.number}: ${error.message}`);
} finally {
decisionsInFlight.delete(item.number);
}
}
toast(`${label}: ${completed}/${items.length} actions submitted.${failures.length ? ` Attention needed: ${failures.join(" ")}` : ""}`,
{ error: failures.length > 0 });
} catch (error) {
toast(error.message, { error: true });
} finally {
bulkBusy = false;
renderBoard();
if (currentItem()) renderDrawerHeader();
}
}
/* ------------------------------------------------------------------ drawer */
function reviewTab(item) {
@@ -541,7 +622,7 @@ function renderDrawerHeader() {
el.rereviewToggle.disabled = Boolean(item.decision) || item.reviewStatus === "queued";
for (const button of el.drawer.querySelectorAll("[data-decision]")) {
button.disabled = Boolean(item.decision) || decisionsInFlight.has(item.number);
button.disabled = bulkBusy || Boolean(item.decision) || decisionsInFlight.has(item.number);
button.classList.remove("confirming");
button.textContent = DECISION_BUTTONS[button.dataset.decision];
}
@@ -580,7 +661,7 @@ function openDrawer(number) {
el.drawer.setAttribute("aria-hidden", "false");
el.rereviewPanel.hidden = true;
if (changed) {
el.comment.value = item.review?.suggestedComment ?? "";
el.comment.value = stagedComment(item);
detail = null;
detailError = null;
}
@@ -634,7 +715,7 @@ function armConfirm(button, kind) {
async function submitDecision(button, kind) {
const item = currentItem();
if (!item || decisionsInFlight.has(item.number)) return;
if (!item || bulkBusy || decisionsInFlight.has(item.number)) return;
if (!armConfirm(button, kind)) return;
clearTimeout(pendingConfirm.timer);
pendingConfirm = null;
@@ -784,6 +865,13 @@ for (const button of el.drawer.querySelectorAll("[data-decision]")) {
button.addEventListener("click", () => submitDecision(button, button.dataset.decision));
}
el.comment.addEventListener("input", () => {
if (selectedNumber !== null) stagedComments.set(selectedNumber, el.comment.value);
clearTimeout(pendingConfirm?.timer);
pendingConfirm = null;
if (currentItem()) renderDrawerHeader();
});
function applySnapshot(snapshot) {
board = snapshot;
renderBoard();
@@ -238,6 +238,13 @@ h2 {
gap: 8px;
}
.column-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 12px 8px;
}
.column-name::before {
content: "";
width: 8px;