mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-14 07:44:43 +00:00
Merge pull request #5042 from github/aaronpowell-fix-open-pr-board-timeout
Add bulk review board actions and fix PR listing timeouts
This commit is contained in:
@@ -21,6 +21,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
|
||||
|
||||
@@ -46,3 +51,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({
|
||||
@@ -128,9 +129,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.`);
|
||||
|
||||
@@ -136,7 +136,8 @@ export class BoardStore {
|
||||
}
|
||||
|
||||
pendingReview(numbers) {
|
||||
const all = Object.values(this.state.items).filter((item) => !item.decision && item.reviewStatus !== "queued");
|
||||
const all = Object.values(this.state.items).filter((item) =>
|
||||
!item.decision && !item.pendingDecision && item.reviewStatus !== "queued");
|
||||
if (numbers?.length) {
|
||||
const wanted = new Set(numbers.map(Number));
|
||||
return all.filter((item) => wanted.has(item.number));
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -144,7 +146,7 @@ function findItem(number) {
|
||||
|
||||
function pendingReviewCount() {
|
||||
return board
|
||||
? board.items.filter((item) => !item.decision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued").length
|
||||
? board.items.filter((item) => !item.decision && !item.pendingDecision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued").length
|
||||
: 0;
|
||||
}
|
||||
|
||||
@@ -173,6 +175,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);
|
||||
@@ -185,6 +188,77 @@ function renderBoard() {
|
||||
}
|
||||
}
|
||||
|
||||
function columnItems(columnId) {
|
||||
return board.items.filter((item) => item.column === columnId && !item.decision &&
|
||||
!item.pendingDecision && item.reviewStatus !== "queued");
|
||||
}
|
||||
|
||||
function stagedComment(item) {
|
||||
return (stagedComments.get(item.number) ?? defaultComment(item))
|
||||
.trim().replace(/^\/(approve|reject)\b\s*/i, "").trim();
|
||||
}
|
||||
|
||||
function renderColumnActions(column) {
|
||||
const count = columnItems(column.id).length;
|
||||
return h("div", { class: "column-actions", role: "group", "aria-label": `${column.title} bulk actions` },
|
||||
["approve", "reject", "rereview"].map((kind) => 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).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).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(
|
||||
@@ -266,7 +340,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,6 +394,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);
|
||||
}
|
||||
|
||||
@@ -485,11 +561,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();
|
||||
@@ -627,6 +708,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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
import { test } from "node:test";
|
||||
|
||||
async function createUI(boardName, items, { confirm = true, request } = {}) {
|
||||
const nodes = new Map();
|
||||
const node = () => ({
|
||||
content: "", value: "", dataset: {},
|
||||
listeners: {},
|
||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
||||
setAttribute() {},
|
||||
querySelectorAll: () => [],
|
||||
});
|
||||
const document = {
|
||||
querySelector: node,
|
||||
querySelectorAll: () => [],
|
||||
getElementById(id) {
|
||||
if (!nodes.has(id)) nodes.set(id, node());
|
||||
return nodes.get(id);
|
||||
},
|
||||
addEventListener() {},
|
||||
};
|
||||
const calls = [], messages = [], confirmations = [];
|
||||
const context = vm.createContext({
|
||||
document, setTimeout, clearTimeout,
|
||||
confirm: (text) => { confirmations.push(text); return confirm; },
|
||||
});
|
||||
const source = await readFile(new URL(`../.github/extensions/${boardName}/public/app.js`, import.meta.url), "utf8");
|
||||
const end = boardName === "open-pr-board" ? "const events = new EventSource" : "function connect()";
|
||||
vm.runInContext(source.slice(0, source.indexOf(end)), context);
|
||||
context.items = items;
|
||||
context.request = async (...args) => {
|
||||
calls.push(args);
|
||||
return request ? request(...args) : { message: "Queued", status: "submitted" };
|
||||
};
|
||||
context.notify = (text, options) => messages.push({ text, options });
|
||||
vm.runInContext(`
|
||||
board = { items, columns: [] };
|
||||
renderBoard = renderDrawer = renderDrawerHeader = () => {};
|
||||
toast = notify;
|
||||
api = request;
|
||||
`, context);
|
||||
return { context, calls, messages, confirmations, nodes };
|
||||
}
|
||||
|
||||
for (const boardName of ["open-pr-board", "external-plugin-review-board"]) {
|
||||
const isPR = boardName === "open-pr-board";
|
||||
const reject = isPR ? "request-changes" : "reject";
|
||||
const item = (number, extra = {}) => ({
|
||||
number, column: "test", headRefOid: "first",
|
||||
review: { suggestedComment: isPR ? `Feedback ${number}` : `/reject Feedback ${number}` },
|
||||
...extra,
|
||||
});
|
||||
const payload = (args) => isPR ? args[1].body : args[2];
|
||||
|
||||
test(`${boardName}: bulk rejection uses each staged comment, including hidden search results`, async () => {
|
||||
const ui = await createUI(boardName, [item(1), item(2)]);
|
||||
vm.runInContext('stagedComments.set(1, "Edited feedback"); filterText = filter = "no matches";', ui.context);
|
||||
await ui.context.bulkAction("test", reject);
|
||||
assert.deepEqual(ui.calls.map(args => ({ ...payload(args) })), [
|
||||
{ number: 1, kind: reject, comment: "Edited feedback" },
|
||||
{ number: 2, kind: reject, comment: "Feedback 2" },
|
||||
]);
|
||||
assert.match(ui.confirmations[0], /Edited feedback/);
|
||||
assert.match(ui.confirmations[0], /hidden search results/);
|
||||
});
|
||||
|
||||
test(`${boardName}: missing feedback blocks the entire batch before confirmation`, async () => {
|
||||
const ui = await createUI(boardName, [item(1), item(2, { review: null })]);
|
||||
await ui.context.bulkAction("test", reject);
|
||||
assert.equal(ui.calls.length, 0);
|
||||
assert.equal(ui.confirmations.length, 0);
|
||||
assert.match(ui.messages[0].text, /#2/);
|
||||
});
|
||||
|
||||
test(`${boardName}: cancelling confirmation never calls an API`, async () => {
|
||||
const ui = await createUI(boardName, [item(1)], { confirm: false });
|
||||
await ui.context.bulkAction("test", "approve");
|
||||
assert.equal(ui.calls.length, 0);
|
||||
});
|
||||
|
||||
test(`${boardName}: rereview sends one batch and excludes queued and actioned items`, async () => {
|
||||
const items = [item(1), item(2), item(3, { reviewStatus: "queued" }), item(4, { decision: { kind: "approve" } })];
|
||||
if (!isPR) items.push(item(5, { pendingDecision: { kind: "reject" } }));
|
||||
const ui = await createUI(boardName, items);
|
||||
await ui.context.bulkAction("test", "rereview");
|
||||
assert.equal(ui.calls.length, 1);
|
||||
assert.deepEqual([...payload(ui.calls[0]).numbers], [1, 2]);
|
||||
assert.match(ui.confirmations[0], /GitHub is not changed/);
|
||||
});
|
||||
|
||||
test(`${boardName}: individual failures are reported and successful items are never retried`, async () => {
|
||||
const ui = await createUI(boardName, [item(1), item(2)], {
|
||||
request: async (...args) => {
|
||||
if (payload(args).number === 1) throw new Error("Offline");
|
||||
return { warning: isPR ? "Approval posted but auto-merge failed" : null };
|
||||
},
|
||||
});
|
||||
await ui.context.bulkAction("test", "approve");
|
||||
assert.equal(ui.calls.length, 2);
|
||||
assert.match(ui.messages.at(-1).text, /1\/2/);
|
||||
assert.match(ui.messages.at(-1).text, /#1: Offline/);
|
||||
if (isPR) assert.match(ui.messages.at(-1).text, /auto-merge failed/);
|
||||
assert.equal(ui.messages.at(-1).options.error, true);
|
||||
});
|
||||
|
||||
test(`${boardName}: an in-flight batch cannot be submitted twice`, async () => {
|
||||
let release;
|
||||
const waiting = new Promise(resolve => { release = resolve; });
|
||||
const ui = await createUI(boardName, [item(1)], {
|
||||
request: async () => { await waiting; return {}; },
|
||||
});
|
||||
const first = ui.context.bulkAction("test", "approve");
|
||||
await ui.context.bulkAction("test", "approve");
|
||||
assert.equal(ui.calls.length, 1);
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
test(`${boardName}: draft edits are kept when switching cards`, async () => {
|
||||
const ui = await createUI(boardName, [item(1)]);
|
||||
vm.runInContext(isPR ? "selectedNumber = 1" : "selected = 1", ui.context);
|
||||
ui.nodes.get("decision-comment").value = "My staged reason";
|
||||
ui.nodes.get("decision-comment").listeners.input();
|
||||
assert.equal(ui.context.stagedComment(ui.context.items[0]), "My staged reason");
|
||||
});
|
||||
}
|
||||
|
||||
test("external-plugin-review-board: pending review count excludes posted decisions", async () => {
|
||||
const ui = await createUI("external-plugin-review-board", [
|
||||
{ number: 1, reviewStatus: "unreviewed" },
|
||||
{ number: 2, reviewStatus: "unreviewed", pendingDecision: { kind: "approve" } },
|
||||
{ number: 3, reviewStatus: "reviewed" },
|
||||
{ number: 4, reviewStatus: "queued" },
|
||||
{ number: 5, decision: { kind: "reject" } },
|
||||
]);
|
||||
|
||||
assert.equal(ui.context.pendingReviewCount(), 1);
|
||||
});
|
||||
|
||||
test("PR bulk close requires and posts the staged reason", async () => {
|
||||
const ui = await createUI("open-pr-board", [{ number: 1, column: "close", review: { suggestedComment: "Duplicate" } }]);
|
||||
await ui.context.bulkAction("close", "close");
|
||||
assert.deepEqual({ ...ui.calls[0][1].body }, { number: 1, kind: "close", comment: "Duplicate" });
|
||||
});
|
||||
|
||||
test("PR changed during a batch is skipped, not approved at a new commit", async () => {
|
||||
const items = [1, 2].map(number => ({ number, column: "approve", headRefOid: "first" }));
|
||||
const ui = await createUI("open-pr-board", items, {
|
||||
request: async () => { items[1].headRefOid = "second"; return {}; },
|
||||
});
|
||||
await ui.context.bulkAction("approve", "approve");
|
||||
assert.equal(ui.calls.length, 1);
|
||||
assert.match(ui.messages.at(-1).text, /#2: PR moved, changed/);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
import { test } from "node:test";
|
||||
import { BoardStore } from "../.github/extensions/external-plugin-review-board/lib/state.mjs";
|
||||
|
||||
test("external-plugin-review-board excludes items with pending decisions from AI review", () => {
|
||||
const store = new BoardStore("unused", "github/awesome-copilot");
|
||||
store.state.items = {
|
||||
"1": { number: 1, reviewStatus: "unreviewed" },
|
||||
"2": { number: 2, reviewStatus: "unreviewed", pendingDecision: { kind: "approve" } },
|
||||
"3": { number: 3, reviewStatus: "queued" },
|
||||
"4": { number: 4, reviewStatus: "unreviewed", decision: { kind: "reject" } },
|
||||
};
|
||||
|
||||
assert.deepEqual(store.pendingReview().map(({ number }) => number), [1]);
|
||||
assert.deepEqual(store.pendingReview([1, 2, 3, 4]).map(({ number }) => number), [1]);
|
||||
});
|
||||
|
||||
test("resetConfirm keeps decision buttons disabled while a decision is pending", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../.github/extensions/external-plugin-review-board/public/app.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const start = source.indexOf("function resetConfirm()");
|
||||
const end = source.indexOf("async function decide(", start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
|
||||
const button = () => ({
|
||||
disabled: false,
|
||||
textContent: "",
|
||||
classList: { remove() {} },
|
||||
});
|
||||
const context = vm.createContext({
|
||||
clearTimeout() {},
|
||||
pendingConfirm: { timer: null, kind: "approve" },
|
||||
ui: { approve: button(), reject: button() },
|
||||
bulkBusy: false,
|
||||
selected: 42,
|
||||
findItem: () => ({ pendingDecision: { kind: "approve" } }),
|
||||
updatePreview() {},
|
||||
});
|
||||
vm.runInContext(source.slice(start, end), context);
|
||||
|
||||
context.resetConfirm();
|
||||
|
||||
assert.equal(context.ui.approve.disabled, true);
|
||||
assert.equal(context.ui.reject.disabled, true);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import vm from "node:vm";
|
||||
import { test } from "node:test";
|
||||
import { BoardStore } from "../.github/extensions/open-pr-board/lib/state.mjs";
|
||||
import { applyDecisionOnGitHub } from "../.github/extensions/open-pr-board/lib/decisions.mjs";
|
||||
import { approveAndEnableAutoMerge } from "../.github/extensions/open-pr-board/lib/github.mjs";
|
||||
import { approveAndEnableAutoMerge, listOpenPullRequests } from "../.github/extensions/open-pr-board/lib/github.mjs";
|
||||
import { REVIEW_FIELDS_SCHEMA } from "../.github/extensions/open-pr-board/lib/review-prompt.mjs";
|
||||
|
||||
const pr = (headRefOid = "first") => ({
|
||||
@@ -59,6 +59,50 @@ test("approval and auto-merge are pinned to the reviewed commit", async () => {
|
||||
assert.deepEqual(calls[2].args, ["pr", "merge", "1", "-R", "example/repo", "--auto", "--merge", "--match-head-commit", headRefOid]);
|
||||
});
|
||||
|
||||
test("open pull requests fetch checks with bounded concurrency, preserve order, and tolerate a failed check request", async () => {
|
||||
const numbers = [41, 7, 23, 4, 99, 12, 68, 51];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const requested = [];
|
||||
const runner = async (args) => {
|
||||
if (args[1] === "list") {
|
||||
return JSON.stringify(numbers.map((number) => ({ number, title: `PR ${number}`, labels: [] })));
|
||||
}
|
||||
if (args[1] === "view") {
|
||||
const number = Number(args[2]);
|
||||
requested.push(number);
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
if (number === 23) throw new Error("check request failed");
|
||||
return JSON.stringify({
|
||||
statusCheckRollup: [{ name: `check-${number}`, status: "COMPLETED", conclusion: "SUCCESS" }],
|
||||
});
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected gh command: ${args.join(" ")}`);
|
||||
};
|
||||
|
||||
const pullRequests = await listOpenPullRequests("example/repo", runner);
|
||||
|
||||
assert.ok(peak <= 6, "check requests should not exceed the six-request limit");
|
||||
assert.ok(peak > 1, "check requests should run concurrently");
|
||||
assert.deepEqual(pullRequests.map(({ number }) => number), numbers, "results should preserve list order");
|
||||
assert.deepEqual(requested.sort((a, b) => a - b), [...numbers].sort((a, b) => a - b), "every PR should request checks");
|
||||
for (const pullRequest of pullRequests) {
|
||||
if (pullRequest.number === 23) {
|
||||
assert.deepEqual(pullRequest.checks, []);
|
||||
assert.equal(pullRequest.checksState, "none");
|
||||
} else {
|
||||
assert.deepEqual(pullRequest.checks.map(({ name }) => name), [`check-${pullRequest.number}`]);
|
||||
assert.equal(pullRequest.checksState, "success");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("approval refuses a missing SHA, changed head, or closed pull request without writing", async () => {
|
||||
let calls = 0;
|
||||
const runner = async () => { calls++; throw new Error("must not run"); };
|
||||
|
||||
Reference in New Issue
Block a user