Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/extensions/external-plugin-review-board/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ 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.
The same read-only refresh runs automatically every 15 minutes while the canvas is open;
concurrent fetches are shared and closing the canvas stops its timer. Selection, staged feedback,
reviews, queue tokens, and local decisions remain intact for submissions still on the board.
Polling never starts AI reviews or writes to GitHub. Failures show an error without clearing
the board; the next scheduled or manual refresh can retry.
- **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
Expand All @@ -37,6 +42,10 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external
Fast-track never overrides an existing review or a bucket you chose; drag a card to *Unreviewed* to opt it
out and have it reviewed normally. Cards show *First party* / *Third party* pills from the intake labels.
- **Details panel** shows the AI review, the rendered issue body, and all comments.
Successful refreshes invalidate cached issue details and reload the selected body/comments
tab without changing staged feedback.
When detail requests overlap, only the latest request for each issue can update its
cache, so an older poll cannot overwrite comments loaded after posting feedback.
Clicking outside the panel or successfully submitting an item action closes it;
failed actions leave it open so you can correct or retry them.
- **Quick actions** post `/approve` (with an optional note) or `/reject <reason>` straight to the issue
Expand Down Expand Up @@ -78,6 +87,8 @@ not imported; boards start fresh.

## Regression tests

Run `node --experimental-vm-modules --test eng/board-periodic-refresh.test.mjs` to check
automatic refresh cadence, concurrent/manual refreshes, state preservation, errors, and disposal.
Run `node --test eng/board-column-order.test.mjs` to check oldest-first column ordering.
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.
Expand Down
14 changes: 10 additions & 4 deletions .github/extensions/external-plugin-review-board/extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,16 @@ async function refresh() {

async function getIssue(number, { force = false } = {}) {
const cached = detailCache.get(number);
if (!force && cached && Date.now() - cached.at < DETAIL_TTL_MS) return cached.data;
const data = await gh.getIssueDetail(store.state.repo, number);
detailCache.set(number, { at: Date.now(), data });
return data;
if (!force && cached?.data && Date.now() - cached.at < DETAIL_TTL_MS) return cached.data;
const request = {};
detailCache.set(number, request);
try {
const data = await gh.getIssueDetail(store.state.repo, number);
if (detailCache.get(number) === request) detailCache.set(number, { at: Date.now(), data });
return data;
} finally {
if (detailCache.get(number) === request) detailCache.delete(number);
}
}

async function startReview({ numbers, instanceId, send }) {
Expand Down
18 changes: 18 additions & 0 deletions .github/extensions/external-plugin-review-board/lib/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const CSP = [
].join("; ");

const MAX_BODY = 64 * 1024;
const REFRESH_INTERVAL_MS = 15 * 60 * 1000;

export function expectedError(message, status) {
return Object.assign(new Error(message), { publicMessage: message, status });
Expand Down Expand Up @@ -168,9 +169,26 @@ export async function startBoardServer({ publicDir, api }) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
port = server.address().port;

let refreshing = false;
const refreshTimer = setInterval(async () => {
Comment thread
aaronpowell marked this conversation as resolved.
if (refreshing) return;
refreshing = true;
try {
await api.routes["POST /api/refresh"]({});
Comment thread
aaronpowell marked this conversation as resolved.
} catch (error) {
if (!Number.isInteger(error?.status) || error.status >= 500) reportError(api, error);
const frame = `event: refresh-error\ndata: ${JSON.stringify({ error: clientErrorMessage(error) })}\n\n`;
for (const client of sseClients) client.write(frame);
} finally {
refreshing = false;
}
}, REFRESH_INTERVAL_MS);
refreshTimer.unref();

return {
url: `http://127.0.0.1:${port}/`,
async close() {
clearInterval(refreshTimer);
unsubscribe();
for (const client of sseClients) client.end();
sseClients.clear();
Expand Down
22 changes: 20 additions & 2 deletions .github/extensions/external-plugin-review-board/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ let filter = "";
let selected = null;
let activeTab = "review";
const details = new Map();
let detailGeneration = 0;
const detailRequests = new Map();
const pendingConfirm = { kind: null, timer: null };
const stagedComments = new Map();
let bulkBusy = false;
Expand Down Expand Up @@ -435,7 +437,7 @@ function renderDrawer() {
for (const tab of ui.tabs) {
tab.setAttribute("aria-selected", String(tab.dataset.tab === activeTab));
if (tab.dataset.tab === "comments") {
const count = details.get(item.number)?.comments.length;
const count = details.get(item.number)?.comments?.length;
tab.textContent = count != null ? `Comments (${count})` : "Comments";
}
}
Expand Down Expand Up @@ -571,13 +573,20 @@ function renderComment(comment) {

async function loadDetail(number, { force = false } = {}) {
if (!force && details.has(number) && !details.get(number).error) return;
const generation = detailGeneration;
const request = {};
detailRequests.set(number, request);
details.delete(number);
if (selected === number) renderDrawer();
try {
const data = await api(force ? "POST" : "GET", `/api/issue/${number}`);
if (generation !== detailGeneration || detailRequests.get(number) !== request) return;
Comment thread
aaronpowell marked this conversation as resolved.
details.set(number, data);
} catch (error) {
if (generation !== detailGeneration || detailRequests.get(number) !== request) return;
details.set(number, { error: error.message });
} finally {
if (detailRequests.get(number) === request) detailRequests.delete(number);
}
if (selected === number) renderDrawer();
}
Expand Down Expand Up @@ -773,6 +782,7 @@ ui.refresh.addEventListener("click", () =>
withBusy(ui.refresh, "Refreshing…", async () => {
try {
const result = await api("POST", "/api/refresh");
++detailGeneration;
details.clear();
const parts = [`${result.total} open`];
if (result.added.length) parts.push(`${result.added.length} new`);
Expand Down Expand Up @@ -883,10 +893,18 @@ document.addEventListener("keydown", (event) => {
function connect() {
const events = new EventSource(`/events?token=${encodeURIComponent(token)}`);
events.addEventListener("board", (event) => {
board = JSON.parse(event.data);
const snapshot = JSON.parse(event.data);
const refreshed = board?.lastRefreshedAt !== snapshot.lastRefreshedAt;
board = snapshot;
if (refreshed) {
++detailGeneration;
details.clear();
}
renderBoard();
if (selected != null) renderDrawer();
if (refreshed && selected != null && activeTab !== "review") loadDetail(selected, { force: true });
Comment thread
aaronpowell marked this conversation as resolved.
});
events.addEventListener("refresh-error", (event) => toast(JSON.parse(event.data).error, { error: true }));
events.addEventListener("error", () => {
ui.meta.textContent = "Reconnecting…";
});
Expand Down
27 changes: 24 additions & 3 deletions .github/extensions/open-pr-board/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ authenticated `gh` CLI.

## What it does

- **Automatic refresh** — fetches open PRs every 15 minutes while the canvas is open,
using the same read-only path as manual Refresh. Concurrent fetches are shared;
closing the canvas stops its timer. Existing reviews, queue tokens, manual buckets,
decisions, selection, and staged feedback are retained for PRs still on the board.
Refresh does not start AI reviews or write to GitHub. Failures show an error without
clearing the board, and the next scheduled or manual refresh can retry.
- **Buckets** — `Unreviewed`, `Reviewing`, `Close`, `Request changes`,
`Needs insight`, `Approve`, `Actioned`. The AI recommendation picks the bucket;
dragging a card overrides it. Cards in each bucket are sorted oldest to newest
Expand Down Expand Up @@ -40,8 +46,21 @@ authenticated `gh` CLI.
- **GitHub actions** — submit feedback, request changes, close a PR, or approve it and
enable auto-merge with a merge commit. Each action needs two clicks to confirm and
is also recorded in local board history. Approval is pinned to the board's commit
SHA, and auto-merge requires that same head commit. If the head changes, refresh
and review the new commit before approving again.
SHA displayed by the client, and auto-merge requires that same head commit.
A changed head automatically reloads an open preview; approval is unavailable until
that preview has loaded. Bulk approval skips stale AI reviews. If the head changes,
review the new commit before approving again.
Queued reviews and manual approval moves retain their original commit SHA, so a
refresh cannot carry those approval choices onto newer commits. Legacy reviews or
approval moves without a recorded SHA must be reviewed or explicitly moved again.
Moves carry the client's displayed SHA even if polling updates the board before the
request arrives. Delayed feedback actions retain their original SHA so new commits
still return to Unreviewed. A successful refresh retries a failed open preview.
Feedback completed for an older head is recorded only in history, preserving any
review queue, completed review, or manual bucket already created for the newer head.
Any retained review, queue, or manual bucket belonging to the older head is cleared.
Feedback requests also carry the displayed SHA and are rejected if polling has
already discovered a newer head before dispatch.
Only one GitHub action can run per PR at a time.
- **Reset board** — after a second click to confirm, clear every local AI review,
queued review, manual move, recorded action, and history entry, then reload open PRs.
Expand Down Expand Up @@ -72,7 +91,7 @@ touching GitHub.

| Action | Purpose |
| ---------------- | ----------------------------------------------------------------------- |
| `get_board` | Current buckets, counts, and per-PR state. |
| `get_board` | Current buckets, counts, per-PR commit SHA, and approval staleness. |
| `refresh` | Re-fetch open pull requests from GitHub. |
| `start_review` | Queue a review pass and return the review instructions. |
| `start_rereview` | Queue a re-review of one PR with optional guidance. |
Expand All @@ -94,6 +113,8 @@ Results arriving after a manual move, reset, or replacement review are rejected.

## Regression tests

Run `node --experimental-vm-modules --test eng/board-periodic-refresh.test.mjs` to check
automatic refresh cadence, concurrent/manual refreshes, state preservation, errors, and disposal.
Run `node --test eng/board-column-order.test.mjs` to check oldest-first column ordering.
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.
Expand Down
26 changes: 19 additions & 7 deletions .github/extensions/open-pr-board/extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,16 @@ async function resetBoard(store) {
async function getPullRequest(store, number, { force = false } = {}) {
const key = cacheKey(store.repo, number);
const cached = detailCache.get(key);
if (!force && cached && Date.now() - cached.at < DETAIL_TTL_MS) return cached.data;
const data = await gh.getPullRequestDetail(store.repo, number);
detailCache.set(key, { at: Date.now(), data });
return data;
if (!force && cached?.data && Date.now() - cached.at < DETAIL_TTL_MS) return cached.data;
const request = {};
detailCache.set(key, request);
try {
const data = await gh.getPullRequestDetail(store.repo, number);
if (detailCache.get(key) === request) detailCache.set(key, { at: Date.now(), data });
return data;
} finally {
if (detailCache.get(key) === request) detailCache.delete(key);
}
}

function requireItem(store, number) {
Expand Down Expand Up @@ -182,6 +188,7 @@ function boardSummary(store) {
counts: snapshot.counts,
items: snapshot.items.map((item) => ({
number: item.number,
headRefOid: item.headRefOid,
title: item.title,
author: item.author,
url: item.url,
Expand All @@ -193,6 +200,7 @@ function boardSummary(store) {
aiRecommendation: item.review?.recommendation ?? null,
aiRationale: item.review?.rationale ?? null,
reviewStale: item.reviewStale,
approvalStale: item.approvalStale,
reviewStatus: item.reviewStatus,
manualOverride: item.manualColumn ?? null,
suggestedComment: item.review?.suggestedComment ?? null,
Expand All @@ -211,11 +219,14 @@ function routesFor(instanceId, store) {
"POST /api/review": ({ body }) => startReview({ store, numbers: body.numbers, instanceId, send: true }),
"POST /api/rereview": ({ body }) =>
startRereview({ store, number: body.number, guidance: body.guidance, instanceId, send: true }),
"POST /api/move": ({ body }) => store.moveItem(Number(body.number), String(body.column)),
"POST /api/move": ({ body }) => store.moveItem(Number(body.number), String(body.column), body.headRefOid),
"GET /api/pr/:number": ({ number }) => getPullRequest(store, number),
"POST /api/pr/:number": ({ number }) => getPullRequest(store, number, { force: true }),
"POST /api/decision": ({ body }) =>
applyDecisionOnGitHub({ store, number: Number(body.number), kind: body.kind, comment: body.comment, refresh }),
applyDecisionOnGitHub({
store, number: Number(body.number), kind: body.kind, comment: body.comment,
headRefOid: body.headRefOid, refresh,
}),
};
// Board errors are user-facing messages (gh failures, "already actioned", ...); surface only the
// message text, never the stack, and log the full error for debugging.
Expand Down Expand Up @@ -343,10 +354,11 @@ session = await joinSession({
repo: repoProperty,
number: { type: "integer" },
column: { type: "string", enum: ["unreviewed", ...RECOMMENDATIONS] },
headRefOid: { type: "string", description: "Commit SHA displayed when choosing the bucket; required for a current manual approval choice." },
Comment thread
aaronpowell marked this conversation as resolved.
},
},
handler: wrap(async (ctx) =>
(await storeForContext(ctx)).moveItem(ctx.input.number, ctx.input.column),
(await storeForContext(ctx)).moveItem(ctx.input.number, ctx.input.column, ctx.input.headRefOid),
),
},
{
Expand Down
14 changes: 11 additions & 3 deletions .github/extensions/open-pr-board/lib/decisions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const DECISION_KINDS = ["approve", "request-changes", "comment", "close"];
const MAX_COMMENT_LENGTH = 60000;
const decisionsInFlight = new Set();

export async function applyDecisionOnGitHub({ store, number, kind, comment, refresh, github = gh }) {
export async function applyDecisionOnGitHub({ store, number, kind, comment, headRefOid, refresh, github = gh }) {
if (!DECISION_KINDS.includes(kind)) throw new Error(`Unknown decision "${kind}".`);
const key = `${store.repo}#${number}`;
if (decisionsInFlight.has(key)) throw new Error(`#${number} already has a GitHub action in progress.`);
Expand All @@ -13,6 +13,11 @@ export async function applyDecisionOnGitHub({ store, number, kind, comment, refr
const item = store.getItem(number);
if (!item) throw new Error(`Pull request #${number} is not on the board. Refresh first.`);
if (item.decision) throw new Error(`#${number} was already actioned (${item.decision.kind}).`);
const actionHeadRefOid = item.headRefOid ?? null;
Comment thread
aaronpowell marked this conversation as resolved.
if ((kind === "comment" || kind === "request-changes") &&
(!headRefOid || headRefOid !== actionHeadRefOid)) {
throw new Error("PR changed since it was displayed; review the current commit before submitting feedback.");
}

const body = String(comment ?? "").trim().slice(0, MAX_COMMENT_LENGTH);
if ((kind === "request-changes" || kind === "comment") && !body) {
Expand All @@ -21,7 +26,10 @@ export async function applyDecisionOnGitHub({ store, number, kind, comment, refr

let result;
if (kind === "approve") {
result = await github.approveAndEnableAutoMerge(store.repo, number, { body, headRefOid: item.headRefOid });
if (!headRefOid || headRefOid !== item.headRefOid) {
throw new Error("PR changed since it was displayed; refresh and review it again before approving.");
}
result = await github.approveAndEnableAutoMerge(store.repo, number, { body, headRefOid });
} else if (kind === "close") {
result = await github.closePullRequest(store.repo, number, { body });
} else {
Expand All @@ -30,7 +38,7 @@ export async function applyDecisionOnGitHub({ store, number, kind, comment, refr

const warnings = result.warning ? [result.warning] : [];
try {
await store.recordDecision(number, { kind, comment: body, status: result.status });
await store.recordDecision(number, { kind, comment: body, status: result.status, headRefOid: actionHeadRefOid });
} catch (error) {
warnings.push(`GitHub was updated, but the board could not record the action: ${error.message}`);
}
Expand Down
1 change: 1 addition & 0 deletions .github/extensions/open-pr-board/lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export async function getPullRequestDetail(repo, number) {
number: pr.number,
title: pr.title,
state: pr.state,
headRefOid: pr.head?.sha,
merged: Boolean(pr.merged),
mergeableState: pr.mergeable_state ?? null,
url: pr.html_url,
Expand Down
18 changes: 18 additions & 0 deletions .github/extensions/open-pr-board/lib/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const CSP = [
].join("; ");

const MAX_BODY = 64 * 1024;
const REFRESH_INTERVAL_MS = 15 * 60 * 1000;

export function expectedError(message, status) {
return Object.assign(new Error(message), { publicMessage: message, status });
Expand Down Expand Up @@ -168,9 +169,26 @@ export async function startBoardServer({ publicDir, api }) {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
port = server.address().port;

let refreshing = false;
const refreshTimer = setInterval(async () => {
Comment thread
aaronpowell marked this conversation as resolved.
if (refreshing || typeof api.routes["POST /api/refresh"] !== "function") return;
refreshing = true;
try {
await api.routes["POST /api/refresh"]({});
Comment thread
aaronpowell marked this conversation as resolved.
Comment thread
aaronpowell marked this conversation as resolved.
Comment thread
aaronpowell marked this conversation as resolved.
} catch (error) {
if (!Number.isInteger(error?.status) || error.status >= 500) reportError(api, error);
const frame = `event: refresh-error\ndata: ${JSON.stringify({ error: clientErrorMessage(error) })}\n\n`;
for (const client of sseClients) client.write(frame);
} finally {
refreshing = false;
}
}, REFRESH_INTERVAL_MS);
refreshTimer.unref();

return {
url: `http://127.0.0.1:${port}/`,
async close() {
clearInterval(refreshTimer);
unsubscribe();
for (const client of sseClients) client.end();
sseClients.clear();
Expand Down
Loading
Loading