-
Notifications
You must be signed in to change notification settings - Fork 5k
Add external plugin review board canvas for maintainers #4174
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # External Plugin Review Board | ||
|
|
||
| A maintainer-only Copilot canvas extension for triaging `external-plugin` submissions that are | ||
| `ready-for-review` in github/awesome-copilot. | ||
|
|
||
| Open it by asking Copilot to "open the plugin review board" (canvas id `external-plugin-review-board`). | ||
|
|
||
| ## Features | ||
|
|
||
| - **Kanban board** with buckets: Unreviewed → Reviewing → Straight reject / Probably reject / Needs review / | ||
| Accept → Actioned. Drag cards between buckets (or use the *Bucket* dropdown in the details panel). | ||
| Dragging a card back to *Unreviewed* clears its AI review so it's picked up by the next review. | ||
| - **Perform review** asks the agent to review every item without an AI review, using | ||
| [`review-guidance.md`](./review-guidance.md) and the decisions you've made from the board. Results are | ||
| recorded with the `record_review` canvas action and move cards into their recommended bucket. | ||
| - **Re-review** (in the details panel) takes optional guidance on what to focus on, then has the agent | ||
| spin up a separate sub-session to re-assess that one submission. The sub-session reports its result | ||
| back and it's recorded on the card along with your guidance. | ||
| - **Refresh** fetches open `external-plugin` + `ready-for-review` issues, adds new ones, and removes any | ||
| that are closed or no longer ready for review. | ||
| - **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. | ||
|
|
||
| ## State | ||
|
|
||
| Board state lives in `state/board.json` next to this file and is gitignored. It stores the synced issue | ||
| metadata, AI reviews, manual bucket overrides, and a history of decisions made from the board. | ||
|
|
||
| ## Requirements | ||
|
|
||
| - The GitHub CLI (`gh`) on `PATH`, authenticated as a maintainer with write access (needed for | ||
| `/approve` and `/reject` to be honoured by the command router workflow). | ||
|
|
||
| ## Agent actions | ||
|
|
||
| `get_board`, `refresh`, `start_review`, `start_rereview`, `record_review`, `move_item`, `get_issue`. |
352 changes: 352 additions & 0 deletions
352
.github/extensions/external-plugin-review-board/extension.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,352 @@ | ||
| // Extension: external-plugin-review-board | ||
| // Kanban board for triaging external plugin submissions with AI review, | ||
| // quick approve/reject, and an issue detail view. Board state is persisted to | ||
| // ./state/board.json (gitignored) so it survives reloads and new sessions. | ||
|
|
||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; | ||
| import { BoardStore, RECOMMENDATIONS } from "./lib/state.mjs"; | ||
| import * as gh from "./lib/github.mjs"; | ||
| import { buildReviewPrompt, buildRereviewPrompt, REVIEW_FIELDS_SCHEMA } from "./lib/review-prompt.mjs"; | ||
| import { startBoardServer, expectedError } from "./lib/server.mjs"; | ||
|
|
||
| const CANVAS_ID = "external-plugin-review-board"; | ||
| const REPO = "github/awesome-copilot"; | ||
| const EXT_DIR = path.dirname(fileURLToPath(import.meta.url)); | ||
| const PUBLIC_DIR = path.join(EXT_DIR, "public"); | ||
| const GUIDANCE_PATH = path.join(EXT_DIR, "review-guidance.md"); | ||
| const DETAIL_TTL_MS = 2 * 60 * 1000; | ||
|
|
||
| const store = await new BoardStore(path.join(EXT_DIR, "state", "board.json"), REPO).load(); | ||
| const servers = new Map(); | ||
| const detailCache = new Map(); | ||
| let refreshInFlight = null; | ||
| let session; | ||
|
|
||
| function sleep(ms) { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
|
|
||
| async function resolvePendingDecisions(numbers = store.pendingDecisionNumbers()) { | ||
| const resolved = []; | ||
| for (const number of await numbers) { | ||
| try { | ||
| const status = await gh.getIssueStatus(store.state.repo, number); | ||
| const decision = await store.resolvePendingDecision(number, status); | ||
| if (decision) { | ||
| detailCache.delete(number); | ||
| resolved.push({ number, kind: decision.kind }); | ||
| } | ||
| } catch (error) { | ||
| session?.log(`Could not resolve pending decision for #${number}: ${error.message}`, { level: "warning" }); | ||
| } | ||
| } | ||
| return resolved; | ||
| } | ||
|
|
||
| async function refresh() { | ||
| refreshInFlight ??= (async () => { | ||
| try { | ||
| await resolvePendingDecisions(); | ||
| const issues = await gh.listReadyIssues(store.state.repo); | ||
| detailCache.clear(); | ||
| return await store.syncIssues(issues); | ||
| } finally { | ||
| refreshInFlight = null; | ||
| } | ||
| })(); | ||
| return refreshInFlight; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| 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." }; | ||
| const queued = items.map((item) => item.number); | ||
| const snapshots = await store.markQueued(queued); | ||
| const prompt = buildReviewPrompt({ | ||
| items, | ||
| instanceId, | ||
| canvasId: CANVAS_ID, | ||
| guidancePath: GUIDANCE_PATH, | ||
| history: store.state.history, | ||
| }); | ||
| if (send) { | ||
| try { | ||
| await session.send({ prompt }); | ||
| } catch (error) { | ||
| await store.restoreQueueState(snapshots); | ||
| throw error; | ||
| } | ||
| return { queued, message: `Asked the agent to review ${queued.length} submission(s).` }; | ||
| } | ||
| return { queued, instructions: prompt }; | ||
| } | ||
|
|
||
| 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.reviewStatus === "queued") throw new Error(`#${item.number} is already queued for review.`); | ||
| const text = String(guidance ?? "").trim().slice(0, MAX_GUIDANCE_LENGTH); | ||
| const snapshots = await store.markQueued([item.number], { rereviewGuidance: text || null }); | ||
| const prompt = buildRereviewPrompt({ | ||
| item, | ||
| guidance: text, | ||
| instanceId, | ||
| canvasId: CANVAS_ID, | ||
| guidancePath: GUIDANCE_PATH, | ||
| history: store.state.history, | ||
| repo: store.state.repo, | ||
| }); | ||
| if (send) { | ||
| try { | ||
| await session.send({ prompt }); | ||
| } catch (error) { | ||
| await store.restoreQueueState(snapshots); | ||
| throw error; | ||
| } | ||
| return { queued: [item.number], message: `Asked the agent to start a re-review sub-session for #${item.number}.` }; | ||
| } | ||
| return { queued: [item.number], instructions: prompt }; | ||
| } | ||
|
|
||
| function stripCommand(text, command) { | ||
| return String(text ?? "") | ||
| .trim() | ||
| .replace(new RegExp(`^/${command}\\b`, "i"), "") | ||
| .trim(); | ||
| } | ||
|
|
||
| async function postDecision({ 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}).`); | ||
|
|
||
| const status = await gh.getIssueStatus(store.state.repo, number); | ||
| if (status.state !== "OPEN") throw new Error(`#${number} is ${status.state.toLowerCase()}. Refresh the board.`); | ||
| if (kind === "approve" && !status.labels.includes("ready-for-review")) { | ||
| throw new Error(`#${number} is no longer labelled ready-for-review, so /approve would be ignored.`); | ||
| } | ||
|
|
||
| const text = stripCommand(comment, kind); | ||
| if (kind === "reject" && !text) throw new Error("Reject decisions require a non-empty reason."); | ||
| const body = kind === "approve" ? (text ? `/approve\n\n${text}` : "/approve") : text ? `/reject ${text}` : "/reject"; | ||
| const commentUrl = await gh.postComment(store.state.repo, number, body); | ||
| const pendingDecision = { kind, comment: text, body, commentUrl, at: new Date().toISOString() }; | ||
| await store.recordPendingDecision(number, pendingDecision); | ||
| for (const delay of [1000, 2000, 4000]) { | ||
| await sleep(delay); | ||
| const resolved = await resolvePendingDecisions([number]); | ||
| if (resolved.length) return { number, ...pendingDecision, pending: false, resolved: resolved[0] }; | ||
| } | ||
| detailCache.delete(number); | ||
| return { number, ...pendingDecision, pending: true }; | ||
| } | ||
|
|
||
| function boardSummary() { | ||
| const snapshot = store.snapshot(); | ||
| return { | ||
| repo: snapshot.repo, | ||
| lastRefreshedAt: snapshot.lastRefreshedAt, | ||
| counts: Object.fromEntries( | ||
| snapshot.columns.map((column) => [column.id, snapshot.items.filter((i) => i.column === column.id).length]), | ||
| ), | ||
| items: snapshot.items.map((item) => ({ | ||
| number: item.number, | ||
| title: item.title, | ||
| author: item.author, | ||
| column: item.column, | ||
| aiRecommendation: item.review?.recommendation ?? null, | ||
| manualOverride: item.manualColumn ?? null, | ||
| suggestedComment: item.review?.suggestedComment ?? null, | ||
| decision: item.decision ? { kind: item.decision.kind, at: item.decision.at } : null, | ||
| pendingDecision: item.pendingDecision | ||
| ? { kind: item.pendingDecision.kind, at: item.pendingDecision.at } | ||
| : null, | ||
| })), | ||
| }; | ||
| } | ||
|
|
||
| function routesFor(instanceId) { | ||
| const routes = { | ||
| "GET /api/board": () => store.snapshot(), | ||
| "POST /api/refresh": () => refresh(), | ||
| "POST /api/review": ({ body }) => startReview({ numbers: body.numbers, instanceId, send: true }), | ||
| "POST /api/rereview": ({ body }) => | ||
| startRereview({ number: body.number, guidance: body.guidance, instanceId, send: true }), | ||
| "POST /api/move": ({ body }) => store.moveItem(Number(body.number), String(body.column)), | ||
| "GET /api/issue/:number": ({ number }) => getIssue(number), | ||
| "POST /api/issue/:number": ({ number }) => getIssue(number, { force: true }), | ||
| "POST /api/decision": ({ body }) => | ||
| postDecision({ number: Number(body.number), kind: body.kind, comment: body.comment }), | ||
| }; | ||
| // Board errors are user-facing messages (e.g. "already actioned", gh failures); surface only the | ||
| // message text, never the stack, and log the full error for debugging. | ||
| return Object.fromEntries( | ||
| Object.entries(routes).map(([key, handler]) => [ | ||
| key, | ||
| async (args) => { | ||
| try { | ||
| return await handler(args); | ||
| } catch (error) { | ||
| session?.log(`Plugin review board ${key} failed: ${error?.stack ?? error}`, { | ||
| level: "warning", | ||
| ephemeral: true, | ||
| }); | ||
| throw expectedError(String(error?.message ?? "Request failed"), 400); | ||
| } | ||
| }, | ||
| ]), | ||
| ); | ||
| } | ||
|
|
||
| function wrap(fn) { | ||
| return async (ctx) => { | ||
| try { | ||
| return await fn(ctx); | ||
| } catch (error) { | ||
| if (error instanceof CanvasError) throw error; | ||
| throw new CanvasError("board_error", error.message ?? String(error)); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| session = await joinSession({ | ||
| canvases: [ | ||
| createCanvas({ | ||
| id: CANVAS_ID, | ||
| displayName: "External Plugin Review Board", | ||
| description: | ||
| "Kanban board for triaging github/awesome-copilot external plugin submissions: AI review, drag between buckets, view issues, and approve/reject.", | ||
| inputSchema: { type: "object", additionalProperties: false, properties: {} }, | ||
| actions: [ | ||
| { | ||
| name: "get_board", | ||
| description: "Return every board item with its column, AI recommendation, suggested comment, and decision.", | ||
| handler: wrap(() => boardSummary()), | ||
| }, | ||
| { | ||
| name: "refresh", | ||
| description: "Fetch open ready-for-review external plugin issues; adds new ones and removes closed ones.", | ||
| handler: wrap(() => refresh()), | ||
| }, | ||
| { | ||
| name: "start_review", | ||
| description: | ||
| "Queue items for AI review (defaults to every item without a review) and return review instructions to follow.", | ||
| inputSchema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| properties: { numbers: { type: "array", items: { type: "integer" } } }, | ||
| }, | ||
| handler: wrap((ctx) => | ||
| startReview({ numbers: ctx.input?.numbers, instanceId: ctx.instanceId, send: false }), | ||
| ), | ||
| }, | ||
| { | ||
| name: "start_rereview", | ||
| description: | ||
| "Queue one item for a guided re-review and return instructions to run it in a sub-session.", | ||
| inputSchema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| required: ["number"], | ||
| properties: { | ||
| number: { type: "integer" }, | ||
| guidance: { type: "string", maxLength: MAX_GUIDANCE_LENGTH }, | ||
| }, | ||
| }, | ||
| handler: wrap((ctx) => | ||
| startRereview({ | ||
| number: ctx.input.number, | ||
| guidance: ctx.input.guidance, | ||
| instanceId: ctx.instanceId, | ||
| send: false, | ||
| }), | ||
| ), | ||
| }, | ||
| { | ||
| name: "record_review", | ||
| description: "Record AI review results for one or more issues; moves each card to its recommended bucket.", | ||
| inputSchema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| required: ["reviews"], | ||
| properties: { reviews: { type: "array", minItems: 1, items: REVIEW_FIELDS_SCHEMA } }, | ||
| }, | ||
| handler: wrap((ctx) => store.recordReviews(ctx.input.reviews)), | ||
| }, | ||
| { | ||
| name: "move_item", | ||
| description: "Manually move an issue to a bucket. Moving to 'unreviewed' clears its AI review.", | ||
| inputSchema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| required: ["number", "column"], | ||
| properties: { | ||
| number: { type: "integer" }, | ||
| column: { type: "string", enum: ["unreviewed", ...RECOMMENDATIONS] }, | ||
| }, | ||
| }, | ||
| handler: wrap((ctx) => store.moveItem(ctx.input.number, ctx.input.column)), | ||
| }, | ||
| { | ||
| name: "get_issue", | ||
| description: "Fetch an issue's body and comments (rendered HTML) plus its stored AI review.", | ||
| inputSchema: { | ||
| type: "object", | ||
| additionalProperties: false, | ||
| required: ["number"], | ||
| properties: { number: { type: "integer" } }, | ||
| }, | ||
| handler: wrap(async (ctx) => ({ | ||
| review: store.getItem(ctx.input.number).review, | ||
| issue: await getIssue(ctx.input.number), | ||
| })), | ||
| }, | ||
| ], | ||
| open: async (ctx) => { | ||
| let entry = servers.get(ctx.instanceId); | ||
| if (!entry) { | ||
| entry = await startBoardServer({ | ||
| publicDir: PUBLIC_DIR, | ||
| api: { | ||
| snapshot: () => store.snapshot(), | ||
| subscribe: (listener) => store.onChange(listener), | ||
| routes: routesFor(ctx.instanceId), | ||
| logger: { | ||
| error: (message, error) => | ||
| session?.log(`${message}: ${error?.stack ?? error}`, { level: "error", ephemeral: true }), | ||
| }, | ||
| }, | ||
| }); | ||
| servers.set(ctx.instanceId, entry); | ||
| } | ||
| if (!store.state.lastRefreshedAt) { | ||
| refresh().catch((error) => | ||
| session?.log(`Plugin review board refresh failed: ${error.message}`, { level: "warning" }), | ||
| ); | ||
| } | ||
| const pending = store.snapshot().items.filter((item) => !item.decision).length; | ||
| return { title: "Plugin review board", status: `${pending} open`, url: entry.url }; | ||
| }, | ||
| onClose: async (ctx) => { | ||
| const entry = servers.get(ctx.instanceId); | ||
| if (entry) { | ||
| servers.delete(ctx.instanceId); | ||
| await entry.close(); | ||
| } | ||
| }, | ||
| }), | ||
| ], | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.