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;