Skip to content

Commit 2b61464

Browse files
authored
Merge branch 'main' into motz-submission-gate-risk-tiers
2 parents def3e5f + 0c54bce commit 2b61464

42 files changed

Lines changed: 7888 additions & 512 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# External Plugin Review Board
2+
3+
A maintainer-only Copilot canvas extension for triaging `external-plugin` submissions that are
4+
`ready-for-review` in github/awesome-copilot.
5+
6+
Open it by asking Copilot to "open the plugin review board" (canvas id `external-plugin-review-board`).
7+
8+
## Features
9+
10+
- **Kanban board** with buckets: Unreviewed → Reviewing → Straight reject / Probably reject / Needs review /
11+
Accept → Actioned. Drag cards between buckets (or use the *Bucket* dropdown in the details panel).
12+
Dragging a card back to *Unreviewed* clears its AI review so it's picked up by the next review.
13+
- **Perform review** asks the agent to review every item without an AI review, using
14+
[`review-guidance.md`](./review-guidance.md) and the decisions you've made from the board. Results are
15+
recorded with the `record_review` canvas action and move cards into their recommended bucket.
16+
- **Re-review** (in the details panel) takes optional guidance on what to focus on, then has the agent
17+
spin up a separate sub-session to re-assess that one submission. The sub-session reports its result
18+
back and it's recorded on the card along with your guidance.
19+
- **Refresh** fetches open `external-plugin` + `ready-for-review` issues, adds new ones, and removes any
20+
that are closed or no longer ready for review.
21+
- **Details panel** shows the AI review, the rendered issue body, and all comments.
22+
- **Quick actions** post `/approve` (with an optional note) or `/reject <reason>` straight to the issue
23+
after a confirm click. The reject reason is prefilled from the AI's suggested comment.
24+
25+
## State
26+
27+
Board state lives in `state/board.json` next to this file and is gitignored. It stores the synced issue
28+
metadata, AI reviews, manual bucket overrides, and a history of decisions made from the board.
29+
30+
## Requirements
31+
32+
- The GitHub CLI (`gh`) on `PATH`, authenticated as a maintainer with write access (needed for
33+
`/approve` and `/reject` to be honoured by the command router workflow).
34+
35+
## Agent actions
36+
37+
`get_board`, `refresh`, `start_review`, `start_rereview`, `record_review`, `move_item`, `get_issue`.
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Extension: external-plugin-review-board
2+
// Kanban board for triaging external plugin submissions with AI review,
3+
// quick approve/reject, and an issue detail view. Board state is persisted to
4+
// ./state/board.json (gitignored) so it survives reloads and new sessions.
5+
6+
import path from "node:path";
7+
import { fileURLToPath } from "node:url";
8+
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
9+
import { BoardStore, RECOMMENDATIONS } from "./lib/state.mjs";
10+
import * as gh from "./lib/github.mjs";
11+
import { buildReviewPrompt, buildRereviewPrompt, REVIEW_FIELDS_SCHEMA } from "./lib/review-prompt.mjs";
12+
import { startBoardServer, expectedError } from "./lib/server.mjs";
13+
14+
const CANVAS_ID = "external-plugin-review-board";
15+
const REPO = "github/awesome-copilot";
16+
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
17+
const PUBLIC_DIR = path.join(EXT_DIR, "public");
18+
const GUIDANCE_PATH = path.join(EXT_DIR, "review-guidance.md");
19+
const DETAIL_TTL_MS = 2 * 60 * 1000;
20+
21+
const store = await new BoardStore(path.join(EXT_DIR, "state", "board.json"), REPO).load();
22+
const servers = new Map();
23+
const detailCache = new Map();
24+
let refreshInFlight = null;
25+
let session;
26+
27+
function sleep(ms) {
28+
return new Promise((resolve) => setTimeout(resolve, ms));
29+
}
30+
31+
async function resolvePendingDecisions(numbers = store.pendingDecisionNumbers()) {
32+
const resolved = [];
33+
for (const number of await numbers) {
34+
try {
35+
const status = await gh.getIssueStatus(store.state.repo, number);
36+
const decision = await store.resolvePendingDecision(number, status);
37+
if (decision) {
38+
detailCache.delete(number);
39+
resolved.push({ number, kind: decision.kind });
40+
}
41+
} catch (error) {
42+
session?.log(`Could not resolve pending decision for #${number}: ${error.message}`, { level: "warning" });
43+
}
44+
}
45+
return resolved;
46+
}
47+
48+
async function refresh() {
49+
refreshInFlight ??= (async () => {
50+
try {
51+
await resolvePendingDecisions();
52+
const issues = await gh.listReadyIssues(store.state.repo);
53+
detailCache.clear();
54+
return await store.syncIssues(issues);
55+
} finally {
56+
refreshInFlight = null;
57+
}
58+
})();
59+
return refreshInFlight;
60+
}
61+
62+
async function getIssue(number, { force = false } = {}) {
63+
const cached = detailCache.get(number);
64+
if (!force && cached && Date.now() - cached.at < DETAIL_TTL_MS) return cached.data;
65+
const data = await gh.getIssueDetail(store.state.repo, number);
66+
detailCache.set(number, { at: Date.now(), data });
67+
return data;
68+
}
69+
70+
async function startReview({ numbers, instanceId, send }) {
71+
const items = store.pendingReview(numbers);
72+
if (!items.length) return { queued: [], message: "Nothing to review — all items already have an AI review." };
73+
const queued = items.map((item) => item.number);
74+
const snapshots = await store.markQueued(queued);
75+
const prompt = buildReviewPrompt({
76+
items,
77+
instanceId,
78+
canvasId: CANVAS_ID,
79+
guidancePath: GUIDANCE_PATH,
80+
history: store.state.history,
81+
});
82+
if (send) {
83+
try {
84+
await session.send({ prompt });
85+
} catch (error) {
86+
await store.restoreQueueState(snapshots);
87+
throw error;
88+
}
89+
return { queued, message: `Asked the agent to review ${queued.length} submission(s).` };
90+
}
91+
return { queued, instructions: prompt };
92+
}
93+
94+
const MAX_GUIDANCE_LENGTH = 4000;
95+
96+
async function startRereview({ number, guidance, instanceId, send }) {
97+
const item = store.getItem(Number(number));
98+
if (item.decision) throw new Error(`#${item.number} was already actioned (${item.decision.kind}).`);
99+
if (item.reviewStatus === "queued") throw new Error(`#${item.number} is already queued for review.`);
100+
const text = String(guidance ?? "").trim().slice(0, MAX_GUIDANCE_LENGTH);
101+
const snapshots = await store.markQueued([item.number], { rereviewGuidance: text || null });
102+
const prompt = buildRereviewPrompt({
103+
item,
104+
guidance: text,
105+
instanceId,
106+
canvasId: CANVAS_ID,
107+
guidancePath: GUIDANCE_PATH,
108+
history: store.state.history,
109+
repo: store.state.repo,
110+
});
111+
if (send) {
112+
try {
113+
await session.send({ prompt });
114+
} catch (error) {
115+
await store.restoreQueueState(snapshots);
116+
throw error;
117+
}
118+
return { queued: [item.number], message: `Asked the agent to start a re-review sub-session for #${item.number}.` };
119+
}
120+
return { queued: [item.number], instructions: prompt };
121+
}
122+
123+
function stripCommand(text, command) {
124+
return String(text ?? "")
125+
.trim()
126+
.replace(new RegExp(`^/${command}\\b`, "i"), "")
127+
.trim();
128+
}
129+
130+
async function postDecision({ number, kind, comment }) {
131+
if (kind !== "approve" && kind !== "reject") throw new Error(`Unknown decision "${kind}".`);
132+
const item = store.getItem(number);
133+
if (item.decision) throw new Error(`#${number} was already actioned (${item.decision.kind}).`);
134+
135+
const status = await gh.getIssueStatus(store.state.repo, number);
136+
if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`);
137+
if (kind === "approve" && !status.labels.includes("ready-for-review")) {
138+
throw new Error(`#${number} is no longer labelled ready-for-review, so /approve would be ignored.`);
139+
}
140+
141+
const text = stripCommand(comment, kind);
142+
if (kind === "reject" && !text) throw new Error("Reject decisions require a non-empty reason.");
143+
const body = kind === "approve" ? (text ? `/approve\n\n${text}` : "/approve") : text ? `/reject ${text}` : "/reject";
144+
const commentUrl = await gh.postComment(store.state.repo, number, body);
145+
const pendingDecision = { kind, comment: text, body, commentUrl, at: new Date().toISOString() };
146+
await store.recordPendingDecision(number, pendingDecision);
147+
for (const delay of [1000, 2000, 4000]) {
148+
await sleep(delay);
149+
const resolved = await resolvePendingDecisions([number]);
150+
if (resolved.length) return { number, ...pendingDecision, pending: false, resolved: resolved[0] };
151+
}
152+
detailCache.delete(number);
153+
return { number, ...pendingDecision, pending: true };
154+
}
155+
156+
function boardSummary() {
157+
const snapshot = store.snapshot();
158+
return {
159+
repo: snapshot.repo,
160+
lastRefreshedAt: snapshot.lastRefreshedAt,
161+
counts: Object.fromEntries(
162+
snapshot.columns.map((column) => [column.id, snapshot.items.filter((i) => i.column === column.id).length]),
163+
),
164+
items: snapshot.items.map((item) => ({
165+
number: item.number,
166+
title: item.title,
167+
author: item.author,
168+
column: item.column,
169+
aiRecommendation: item.review?.recommendation ?? null,
170+
manualOverride: item.manualColumn ?? null,
171+
suggestedComment: item.review?.suggestedComment ?? null,
172+
decision: item.decision ? { kind: item.decision.kind, at: item.decision.at } : null,
173+
pendingDecision: item.pendingDecision
174+
? { kind: item.pendingDecision.kind, at: item.pendingDecision.at }
175+
: null,
176+
})),
177+
};
178+
}
179+
180+
function routesFor(instanceId) {
181+
const routes = {
182+
"GET /api/board": () => store.snapshot(),
183+
"POST /api/refresh": () => refresh(),
184+
"POST /api/review": ({ body }) => startReview({ numbers: body.numbers, instanceId, send: true }),
185+
"POST /api/rereview": ({ body }) =>
186+
startRereview({ number: body.number, guidance: body.guidance, instanceId, send: true }),
187+
"POST /api/move": ({ body }) => store.moveItem(Number(body.number), String(body.column)),
188+
"GET /api/issue/:number": ({ number }) => getIssue(number),
189+
"POST /api/issue/:number": ({ number }) => getIssue(number, { force: true }),
190+
"POST /api/decision": ({ body }) =>
191+
postDecision({ number: Number(body.number), kind: body.kind, comment: body.comment }),
192+
};
193+
// Board errors are user-facing messages (e.g. "already actioned", gh failures); surface only the
194+
// message text, never the stack, and log the full error for debugging.
195+
return Object.fromEntries(
196+
Object.entries(routes).map(([key, handler]) => [
197+
key,
198+
async (args) => {
199+
try {
200+
return await handler(args);
201+
} catch (error) {
202+
session?.log(`Plugin review board ${key} failed: ${error?.stack ?? error}`, {
203+
level: "warning",
204+
ephemeral: true,
205+
});
206+
throw expectedError(String(error?.message ?? "Request failed"), 400);
207+
}
208+
},
209+
]),
210+
);
211+
}
212+
213+
function wrap(fn) {
214+
return async (ctx) => {
215+
try {
216+
return await fn(ctx);
217+
} catch (error) {
218+
if (error instanceof CanvasError) throw error;
219+
throw new CanvasError("board_error", error.message ?? String(error));
220+
}
221+
};
222+
}
223+
224+
session = await joinSession({
225+
canvases: [
226+
createCanvas({
227+
id: CANVAS_ID,
228+
displayName: "External Plugin Review Board",
229+
description:
230+
"Kanban board for triaging github/awesome-copilot external plugin submissions: AI review, drag between buckets, view issues, and approve/reject.",
231+
inputSchema: { type: "object", additionalProperties: false, properties: {} },
232+
actions: [
233+
{
234+
name: "get_board",
235+
description: "Return every board item with its column, AI recommendation, suggested comment, and decision.",
236+
handler: wrap(() => boardSummary()),
237+
},
238+
{
239+
name: "refresh",
240+
description: "Fetch open ready-for-review external plugin issues; adds new ones and removes closed ones.",
241+
handler: wrap(() => refresh()),
242+
},
243+
{
244+
name: "start_review",
245+
description:
246+
"Queue items for AI review (defaults to every item without a review) and return review instructions to follow.",
247+
inputSchema: {
248+
type: "object",
249+
additionalProperties: false,
250+
properties: { numbers: { type: "array", items: { type: "integer" } } },
251+
},
252+
handler: wrap((ctx) =>
253+
startReview({ numbers: ctx.input?.numbers, instanceId: ctx.instanceId, send: false }),
254+
),
255+
},
256+
{
257+
name: "start_rereview",
258+
description:
259+
"Queue one item for a guided re-review and return instructions to run it in a sub-session.",
260+
inputSchema: {
261+
type: "object",
262+
additionalProperties: false,
263+
required: ["number"],
264+
properties: {
265+
number: { type: "integer" },
266+
guidance: { type: "string", maxLength: MAX_GUIDANCE_LENGTH },
267+
},
268+
},
269+
handler: wrap((ctx) =>
270+
startRereview({
271+
number: ctx.input.number,
272+
guidance: ctx.input.guidance,
273+
instanceId: ctx.instanceId,
274+
send: false,
275+
}),
276+
),
277+
},
278+
{
279+
name: "record_review",
280+
description: "Record AI review results for one or more issues; moves each card to its recommended bucket.",
281+
inputSchema: {
282+
type: "object",
283+
additionalProperties: false,
284+
required: ["reviews"],
285+
properties: { reviews: { type: "array", minItems: 1, items: REVIEW_FIELDS_SCHEMA } },
286+
},
287+
handler: wrap((ctx) => store.recordReviews(ctx.input.reviews)),
288+
},
289+
{
290+
name: "move_item",
291+
description: "Manually move an issue to a bucket. Moving to 'unreviewed' clears its AI review.",
292+
inputSchema: {
293+
type: "object",
294+
additionalProperties: false,
295+
required: ["number", "column"],
296+
properties: {
297+
number: { type: "integer" },
298+
column: { type: "string", enum: ["unreviewed", ...RECOMMENDATIONS] },
299+
},
300+
},
301+
handler: wrap((ctx) => store.moveItem(ctx.input.number, ctx.input.column)),
302+
},
303+
{
304+
name: "get_issue",
305+
description: "Fetch an issue's body and comments (rendered HTML) plus its stored AI review.",
306+
inputSchema: {
307+
type: "object",
308+
additionalProperties: false,
309+
required: ["number"],
310+
properties: { number: { type: "integer" } },
311+
},
312+
handler: wrap(async (ctx) => ({
313+
review: store.getItem(ctx.input.number).review,
314+
issue: await getIssue(ctx.input.number),
315+
})),
316+
},
317+
],
318+
open: async (ctx) => {
319+
let entry = servers.get(ctx.instanceId);
320+
if (!entry) {
321+
entry = await startBoardServer({
322+
publicDir: PUBLIC_DIR,
323+
api: {
324+
snapshot: () => store.snapshot(),
325+
subscribe: (listener) => store.onChange(listener),
326+
routes: routesFor(ctx.instanceId),
327+
logger: {
328+
error: (message, error) =>
329+
session?.log(`${message}: ${error?.stack ?? error}`, { level: "error", ephemeral: true }),
330+
},
331+
},
332+
});
333+
servers.set(ctx.instanceId, entry);
334+
}
335+
if (!store.state.lastRefreshedAt) {
336+
refresh().catch((error) =>
337+
session?.log(`Plugin review board refresh failed: ${error.message}`, { level: "warning" }),
338+
);
339+
}
340+
const pending = store.snapshot().items.filter((item) => !item.decision).length;
341+
return { title: "Plugin review board", status: `${pending} open`, url: entry.url };
342+
},
343+
onClose: async (ctx) => {
344+
const entry = servers.get(ctx.instanceId);
345+
if (entry) {
346+
servers.delete(ctx.instanceId);
347+
await entry.close();
348+
}
349+
},
350+
}),
351+
],
352+
});

0 commit comments

Comments
 (0)