mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-15 08:11:44 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user