From b745ac9428294bead2f152085c495a8d2497c74a Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 29 Sep 2026 15:16:17 +1000 Subject: [PATCH 1/3] feat: add external plugin review board canvas extension Project-scoped Copilot canvas for maintainers to triage external plugin submissions: kanban buckets, AI review, guided re-review via sub-session, refresh, issue/comments view, and quick /approve or /reject. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../external-plugin-review-board/README.md | 37 + .../extension.mjs | 305 ++++++++ .../lib/github.mjs | 99 +++ .../lib/review-prompt.mjs | 103 +++ .../lib/server.mjs | 148 ++++ .../lib/state.mjs | 214 ++++++ .../public/app.js | 629 ++++++++++++++++ .../public/index.html | 79 ++ .../public/styles.css | 675 ++++++++++++++++++ .../review-guidance.md | 67 ++ .gitignore | 3 + 11 files changed, 2359 insertions(+) create mode 100644 .github/extensions/external-plugin-review-board/README.md create mode 100644 .github/extensions/external-plugin-review-board/extension.mjs create mode 100644 .github/extensions/external-plugin-review-board/lib/github.mjs create mode 100644 .github/extensions/external-plugin-review-board/lib/review-prompt.mjs create mode 100644 .github/extensions/external-plugin-review-board/lib/server.mjs create mode 100644 .github/extensions/external-plugin-review-board/lib/state.mjs create mode 100644 .github/extensions/external-plugin-review-board/public/app.js create mode 100644 .github/extensions/external-plugin-review-board/public/index.html create mode 100644 .github/extensions/external-plugin-review-board/public/styles.css create mode 100644 .github/extensions/external-plugin-review-board/review-guidance.md diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md new file mode 100644 index 0000000000..50da0dbb71 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/README.md @@ -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 ` 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`, `post_decision`. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs new file mode 100644 index 0000000000..79462d1025 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -0,0 +1,305 @@ +// 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 } 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; + +async function refresh() { + refreshInFlight ??= (async () => { + try { + 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); + await store.markQueued(queued); + const prompt = buildReviewPrompt({ + items, + instanceId, + canvasId: CANVAS_ID, + guidancePath: GUIDANCE_PATH, + history: store.state.history, + }); + if (send) { + await session.send({ prompt }); + 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 prompt = buildRereviewPrompt({ + item, + guidance: text, + instanceId, + canvasId: CANVAS_ID, + guidancePath: GUIDANCE_PATH, + history: store.state.history, + repo: store.state.repo, + }); + await store.markQueued([item.number], { rereviewGuidance: text || null }); + if (send) { + await session.send({ prompt }); + 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); + 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 decision = { kind, comment: text, body, commentUrl, at: new Date().toISOString() }; + await store.recordDecision(number, decision); + detailCache.delete(number); + return { number, ...decision }; +} + +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, + })), + }; +} + +function routesFor(instanceId) { + return { + "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 }), + }; +} + +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), + })), + }, + { + name: "post_decision", + description: + "Post /approve or /reject on an issue (publicly visible; only use when the user asked). Marks it actioned.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["number", "kind"], + properties: { + number: { type: "integer" }, + kind: { type: "string", enum: ["approve", "reject"] }, + comment: { type: "string" }, + }, + }, + handler: wrap((ctx) => postDecision(ctx.input)), + }, + ], + 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), + }, + }); + 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(); + } + }, + }), + ], +}); diff --git a/.github/extensions/external-plugin-review-board/lib/github.mjs b/.github/extensions/external-plugin-review-board/lib/github.mjs new file mode 100644 index 0000000000..128a2c8477 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/lib/github.mjs @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; + +function runGh(args, { input } = {}) { + return new Promise((resolve, reject) => { + const child = spawn("gh", args, { windowsHide: true, env: process.env }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("error", (error) => { + reject( + new Error( + error.code === "ENOENT" + ? "GitHub CLI (gh) was not found on PATH. Install it and run `gh auth login`." + : error.message, + ), + ); + }); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr.trim() || `gh exited with code ${code}`)); + } + }); + child.stdin.end(input ?? undefined); + }); +} + +export async function listReadyIssues(repo) { + const out = await runGh([ + "issue", + "list", + "-R", + repo, + "--label", + "external-plugin", + "--label", + "ready-for-review", + "--state", + "open", + "--limit", + "500", + "--json", + "number,title,author,createdAt,updatedAt,labels,url", + ]); + return JSON.parse(out).map((issue) => ({ + number: issue.number, + title: issue.title, + author: issue.author?.login ?? "unknown", + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + url: issue.url, + labels: (issue.labels ?? []).map((label) => label.name), + })); +} + +const FULL_JSON = ["-H", "Accept: application/vnd.github.full+json"]; + +export async function getIssueDetail(repo, number) { + const [issueRaw, commentsRaw] = await Promise.all([ + runGh(["api", `repos/${repo}/issues/${number}`, ...FULL_JSON]), + runGh(["api", `repos/${repo}/issues/${number}/comments?per_page=100`, "--paginate", "--slurp", ...FULL_JSON]), + ]); + const issue = JSON.parse(issueRaw); + const comments = JSON.parse(commentsRaw).flat(); + return { + number: issue.number, + title: issue.title, + state: issue.state, + url: issue.html_url, + author: issue.user?.login, + createdAt: issue.created_at, + labels: (issue.labels ?? []).map((label) => label.name), + bodyHtml: issue.body_html ?? "", + comments: comments.map((comment) => ({ + id: comment.id, + author: comment.user?.login, + authorType: comment.user?.type, + association: comment.author_association, + createdAt: comment.created_at, + url: comment.html_url, + bodyHtml: comment.body_html ?? "", + })), + }; +} + +export async function getIssueStatus(repo, number) { + const out = await runGh(["issue", "view", String(number), "-R", repo, "--json", "state,labels"]); + const data = JSON.parse(out); + return { state: data.state, labels: (data.labels ?? []).map((label) => label.name) }; +} + +export async function postComment(repo, number, body) { + const out = await runGh(["issue", "comment", String(number), "-R", repo, "--body-file", "-"], { input: body }); + return out.trim(); +} diff --git a/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs b/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs new file mode 100644 index 0000000000..178b2a8ab0 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs @@ -0,0 +1,103 @@ +export const REVIEW_FIELDS_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["number", "recommendation", "rationale"], + properties: { + number: { type: "integer", description: "Issue number." }, + recommendation: { + type: "string", + enum: ["straight-reject", "probably-reject", "needs-review", "accept"], + }, + pluginName: { type: "string" }, + repository: { type: "string", description: "owner/repo of the submitted plugin." }, + repoFacts: { type: "string", description: "Repo age, stars, commits, author account notes." }, + signals: { type: "string", description: "Automated signals: AGT risk, gate warnings, pricing heuristics." }, + contents: { type: "string", description: "What the plugin actually contains." }, + commercial: { type: "string", description: "Is it a pitch for a paid or brand-new product? Evidence." }, + rationale: { type: "string", description: "1-2 sentence justification referencing the decision pattern." }, + checkNext: { type: "string", description: "For probably-reject/needs-review: what the maintainer should look at." }, + rereviewGuidance: { type: "string", description: "Maintainer guidance that prompted this re-review, if any." }, + suggestedComment: { + type: "string", + description: "Suggested maintainer comment, starting with /approve or /reject .", + }, + }, +}; + +const REVIEW_FIELD_LIST = + "number, recommendation (straight-reject | probably-reject | needs-review | accept), pluginName, repository, repoFacts, signals, contents, commercial, rationale, checkNext (optional), suggestedComment (e.g. \"/reject \" or \"/approve\")"; + +function formatHistory(history) { + return history.length + ? history + .slice(0, 25) + .map( + (entry) => + `- #${entry.number} ${entry.title}: /${entry.kind}${entry.comment ? ` ${entry.comment}` : ""}` + + (entry.aiRecommendation ? ` (AI suggested ${entry.aiRecommendation})` : ""), + ) + .join("\n") + : "- (none recorded from the board yet)"; +} + +export function buildRereviewPrompt({ item, guidance, instanceId, canvasId, guidancePath, history, repo }) { + const previous = item.review + ? Object.entries(item.review) + .filter(([key, value]) => value && key !== "rereviewGuidance") + .map(([key, value]) => ` - ${key}: ${value}`) + .join("\n") + : " - (no previous AI review)"; + const focus = guidance + ? `The maintainer asked for this re-review with the following guidance. Treat it as the main question to answer, and let it override the previous assessment where the evidence supports that:\n"""\n${guidance}\n"""` + : "The maintainer didn't give specific guidance; take a fresh, independent look."; + + const childPrompt = `Re-review external plugin submission #${item.number} ("${item.title}" by @${item.author}) in ${repo}. + +Read the review guidance first (use the view tool, absolute path): ${guidancePath} +Follow its per-issue procedure. Work strictly read-only: do NOT comment on, label, close, or modify any issue or repository, and do not edit files. + +${focus} + +Previous AI review for context: +${previous} + +Recent maintainer decisions (calibrate against these as well as the guidance): +${formatHistory(history)} + +When you're done, send your result back to the session that created you (your creator) as a single message containing ONLY a JSON object with these fields: ${REVIEW_FIELD_LIST}. In the rationale, explicitly address the maintainer's guidance.`; + + return `The maintainer requested a guided re-review of #${item.number} from the External Plugin Review Board canvas. Run it as a separate sub-session; do NOT perform the review yourself in this session. + +1. Call create_session with name "Re-review #${item.number}", coordinate_with_creator true, notify_on_idle "once", and kickoff { mode: "autopilot", prompt: }. +2. Reply briefly that the re-review has started, then end your turn. +3. When the sub-session sends back its JSON result, record it by calling invoke_canvas_action with instanceId "${instanceId}", actionName "record_review", input { "reviews": [ ] }. If that instance is no longer open, first call open_canvas with canvasId "${canvasId}" and the same instanceId. Then summarise the outcome in one line (old bucket → new bucket). If the sub-session fails or never reports back, say so instead of reviewing it yourself. + +----- BEGIN SUB-SESSION PROMPT ----- +${childPrompt} +----- END SUB-SESSION PROMPT -----`; +} + +export function buildReviewPrompt({ items, instanceId, canvasId, guidancePath, history }) { + const list = items + .map((item) => `- #${item.number} ${item.title} (by @${item.author}; labels: ${item.labels.join(", ") || "none"})`) + .join("\n"); + + const recent = formatHistory(history); + + return `Perform an AI review of ${items.length} external plugin submission(s) on the External Plugin Review Board canvas. + +Read the review guidance first (use the view tool): ${guidancePath} +It contains the maintainer's decision pattern, the per-issue review procedure, and the recommendation buckets. Work strictly read-only: do NOT comment on, label, or close any issue. + +Recent decisions made from the board (calibrate against these as well as the guidance): +${recent} + +Submissions to review: +${list} + +For more than ~5 submissions, split them across parallel general-purpose sub-agents (give each the guidance path and its issue numbers, and ask them to return the fields below), grouping submissions from the same author together so bulk submissions are assessed as a batch. + +Record results on the board as you go by calling invoke_canvas_action with instanceId "${instanceId}", actionName "record_review", input { "reviews": [ ... ] }. Each review object has: ${REVIEW_FIELD_LIST}. If that instance is no longer open, first call open_canvas with canvasId "${canvasId}" and the same instanceId. + +When finished, reply with a short summary of the counts per bucket.`; +} diff --git a/.github/extensions/external-plugin-review-board/lib/server.mjs b/.github/extensions/external-plugin-review-board/lib/server.mjs new file mode 100644 index 0000000000..9abb2b176b --- /dev/null +++ b/.github/extensions/external-plugin-review-board/lib/server.mjs @@ -0,0 +1,148 @@ +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const STATIC_FILES = { + "/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" }, + "/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" }, +}; + +const CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https: data:", + "media-src https:", + "connect-src 'self'", + "frame-ancestors *", +].join("; "); + +const MAX_BODY = 64 * 1024; + +function readJson(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY) { + reject(Object.assign(new Error("Request body too large"), { status: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (!chunks.length) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + reject(Object.assign(new Error("Invalid JSON body"), { status: 400 })); + } + }); + req.on("error", reject); + }); +} + +function sendJson(res, status, payload) { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); + res.end(JSON.stringify(payload)); +} + +/** + * Starts a loopback server for one canvas instance. + * `api` provides: snapshot(), subscribe(fn) => unsubscribe, and route handlers keyed by "METHOD /path". + */ +export async function startBoardServer({ publicDir, api }) { + const token = randomBytes(24).toString("hex"); + const sseClients = new Set(); + let port = 0; + + const unsubscribe = api.subscribe((snapshot) => { + const frame = `event: board\ndata: ${JSON.stringify(snapshot)}\n\n`; + for (const client of sseClients) client.write(frame); + }); + + const server = createServer(async (req, res) => { + try { + const host = req.headers.host ?? ""; + if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) { + res.writeHead(421).end("Misdirected request"); + return; + } + const url = new URL(req.url ?? "/", `http://${host}`); + + if (req.method === "GET" && url.pathname === "/") { + const html = await readFile(path.join(publicDir, "index.html"), "utf8"); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": CSP, + "Cache-Control": "no-store", + }); + res.end(html.replace("__BOARD_TOKEN__", token)); + return; + } + + const asset = req.method === "GET" && STATIC_FILES[url.pathname]; + if (asset) { + const body = await readFile(path.join(publicDir, asset.file)); + res.writeHead(200, { "Content-Type": asset.type, "Cache-Control": "no-store" }); + res.end(body); + return; + } + + if (!url.pathname.startsWith("/api/") && url.pathname !== "/events") { + res.writeHead(404).end("Not found"); + return; + } + + if (url.searchParams.get("token") !== token && req.headers["x-board-token"] !== token) { + sendJson(res, 403, { error: "Invalid board token" }); + return; + } + + if (req.method === "GET" && url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + Connection: "keep-alive", + }); + res.write(`event: board\ndata: ${JSON.stringify(api.snapshot())}\n\n`); + sseClients.add(res); + const keepAlive = setInterval(() => res.write(": ping\n\n"), 25000); + req.on("close", () => { + clearInterval(keepAlive); + sseClients.delete(res); + }); + return; + } + + const routeKey = `${req.method} ${url.pathname.replace(/\/\d+$/, "/:number")}`; + const handler = api.routes[routeKey]; + if (!handler) { + sendJson(res, 404, { error: `No route for ${req.method} ${url.pathname}` }); + return; + } + const numberMatch = url.pathname.match(/\/(\d+)$/); + const body = req.method === "POST" ? await readJson(req) : {}; + const result = await handler({ body, number: numberMatch ? Number(numberMatch[1]) : undefined }); + sendJson(res, 200, result ?? {}); + } catch (error) { + sendJson(res, error.status ?? 500, { error: error.message ?? String(error) }); + } + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + port = server.address().port; + + return { + url: `http://127.0.0.1:${port}/`, + async close() { + unsubscribe(); + for (const client of sseClients) client.end(); + sseClients.clear(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs new file mode 100644 index 0000000000..5ef5f7ccf5 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -0,0 +1,214 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export const RECOMMENDATIONS = ["straight-reject", "probably-reject", "needs-review", "accept"]; + +export const COLUMNS = [ + { id: "unreviewed", title: "Unreviewed", droppable: true }, + { id: "reviewing", title: "Reviewing", droppable: false }, + { id: "straight-reject", title: "Straight reject", droppable: true }, + { id: "probably-reject", title: "Probably reject", droppable: true }, + { id: "needs-review", title: "Needs review", droppable: true }, + { id: "accept", title: "Accept", droppable: true }, + { id: "actioned", title: "Actioned", droppable: false }, +]; + +const HISTORY_LIMIT = 200; + +function emptyState(repo) { + return { version: 1, repo, lastRefreshedAt: null, items: {}, history: [] }; +} + +export function effectiveColumn(item) { + if (item.decision) return "actioned"; + if (item.manualColumn) return item.manualColumn; + if (item.reviewStatus === "queued") return "reviewing"; + if (item.review?.recommendation) return item.review.recommendation; + return "unreviewed"; +} + +export class BoardStore { + constructor(filePath, repo) { + this.filePath = filePath; + this.repo = repo; + this.state = emptyState(repo); + this.listeners = new Set(); + this.writeChain = Promise.resolve(); + } + + async load() { + try { + const raw = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw); + this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo }; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + return this; + } + + onChange(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async commit() { + const data = JSON.stringify(this.state, null, 2) + "\n"; + this.writeChain = this.writeChain + .catch(() => {}) + .then(async () => { + await mkdir(path.dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.tmp`; + await writeFile(tmp, data, "utf8"); + await rename(tmp, this.filePath); + }); + await this.writeChain; + const snapshot = this.snapshot(); + for (const listener of this.listeners) { + try { + listener(snapshot); + } catch { + // listeners are best-effort UI pushes + } + } + } + + getItem(number) { + const item = this.state.items[String(number)]; + if (!item) throw new Error(`Issue #${number} is not on the board. Refresh first.`); + return item; + } + + snapshot() { + const items = Object.values(this.state.items) + .map((item) => ({ ...item, column: effectiveColumn(item) })) + .sort((a, b) => b.number - a.number); + return { + repo: this.state.repo, + lastRefreshedAt: this.state.lastRefreshedAt, + columns: COLUMNS, + items, + history: this.state.history.slice(0, 50), + }; + } + + async syncIssues(issues) { + const incoming = new Map(issues.map((issue) => [String(issue.number), issue])); + const added = []; + const removed = []; + for (const [key, issue] of incoming) { + const existing = this.state.items[key]; + if (existing) { + Object.assign(existing, { + title: issue.title, + author: issue.author, + url: issue.url, + labels: issue.labels, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }); + } else { + this.state.items[key] = { + ...issue, + reviewStatus: "unreviewed", + review: null, + manualColumn: null, + decision: null, + addedAt: new Date().toISOString(), + }; + added.push(issue.number); + } + } + for (const key of Object.keys(this.state.items)) { + if (!incoming.has(key)) { + removed.push(Number(key)); + delete this.state.items[key]; + } + } + this.state.lastRefreshedAt = new Date().toISOString(); + await this.commit(); + return { added, removed, total: incoming.size }; + } + + pendingReview(numbers) { + const all = Object.values(this.state.items).filter((item) => !item.decision); + if (numbers?.length) { + const wanted = new Set(numbers.map(Number)); + return all.filter((item) => wanted.has(item.number)); + } + return all.filter((item) => item.reviewStatus !== "reviewed"); + } + + async markQueued(numbers, { rereviewGuidance } = {}) { + const now = new Date().toISOString(); + for (const number of numbers) { + const item = this.getItem(number); + item.reviewStatus = "queued"; + item.queuedAt = now; + if (rereviewGuidance !== undefined) item.rereviewGuidance = rereviewGuidance; + else delete item.rereviewGuidance; + } + await this.commit(); + } + + async recordReviews(reviews) { + const recorded = []; + const skipped = []; + for (const review of reviews) { + const item = this.state.items[String(review.number)]; + if (!item) { + skipped.push({ number: review.number, reason: "not on board" }); + continue; + } + if (!RECOMMENDATIONS.includes(review.recommendation)) { + skipped.push({ number: review.number, reason: `invalid recommendation ${review.recommendation}` }); + continue; + } + const { number, ...rest } = review; + item.review = { ...rest, reviewedAt: new Date().toISOString() }; + item.reviewStatus = "reviewed"; + item.manualColumn = null; + delete item.queuedAt; + delete item.rereviewGuidance; + recorded.push(number); + } + await this.commit(); + return { recorded, skipped }; + } + + async moveItem(number, column) { + const item = this.getItem(number); + if (item.decision) throw new Error(`#${number} has already been actioned.`); + if (column === "unreviewed") { + item.review = null; + item.reviewStatus = "unreviewed"; + item.manualColumn = null; + delete item.queuedAt; + } else if (RECOMMENDATIONS.includes(column)) { + item.manualColumn = item.review?.recommendation === column ? null : column; + if (item.reviewStatus === "queued") item.reviewStatus = item.review ? "reviewed" : "unreviewed"; + delete item.rereviewGuidance; + } else { + throw new Error(`Items cannot be moved to "${column}".`); + } + await this.commit(); + return { number, column: effectiveColumn(item) }; + } + + async recordDecision(number, decision) { + const item = this.getItem(number); + item.decision = decision; + this.state.history.unshift({ + number, + title: item.title, + author: item.author, + kind: decision.kind, + comment: decision.comment ?? "", + aiRecommendation: item.review?.recommendation ?? null, + boardColumn: item.manualColumn ?? item.review?.recommendation ?? null, + at: decision.at, + }); + this.state.history = this.state.history.slice(0, HISTORY_LIMIT); + await this.commit(); + } +} diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js new file mode 100644 index 0000000000..631f20378e --- /dev/null +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -0,0 +1,629 @@ +const token = document.querySelector('meta[name="board-token"]').content; + +const RECOMMENDATION_LABELS = { + "straight-reject": "Straight reject", + "probably-reject": "Probably reject", + "needs-review": "Needs review", + accept: "Accept", +}; + +const ui = { + board: document.getElementById("board"), + meta: document.getElementById("meta"), + search: document.getElementById("search"), + refresh: document.getElementById("refresh"), + review: document.getElementById("review"), + drawer: document.getElementById("drawer"), + drawerKicker: document.getElementById("drawer-kicker"), + drawerTitle: document.getElementById("drawer-title"), + drawerMeta: document.getElementById("drawer-meta"), + drawerClose: document.getElementById("drawer-close"), + drawerMove: document.getElementById("drawer-move"), + drawerRereview: document.getElementById("drawer-rereview"), + rereviewPanel: document.getElementById("rereview-panel"), + rereviewGuidance: document.getElementById("rereview-guidance"), + rereviewCancel: document.getElementById("rereview-cancel"), + rereviewStart: document.getElementById("rereview-start"), + drawerLink: document.getElementById("drawer-link"), + drawerBody: document.getElementById("drawer-body"), + drawerFooter: document.getElementById("drawer-footer"), + tabs: [...document.querySelectorAll(".tab")], + comment: document.getElementById("decision-comment"), + preview: document.getElementById("decision-preview"), + approve: document.getElementById("decision-approve"), + reject: document.getElementById("decision-reject"), + toasts: document.getElementById("toasts"), +}; + +let board = null; +let filter = ""; +let selected = null; +let activeTab = "review"; +const details = new Map(); +const pendingConfirm = { kind: null, timer: null }; + +// ---------- helpers ---------- + +function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value == null || value === false) continue; + if (key === "class") el.className = value; + else if (key === "dataset") Object.assign(el.dataset, value); + else if (key.startsWith("on")) el.addEventListener(key.slice(2), value); + else el.setAttribute(key, value === true ? "" : value); + } + for (const child of children.flat()) { + if (child == null || child === false) continue; + el.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return el; +} + +async function api(method, url, body) { + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json", "X-Board-Token": token }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error ?? `Request failed (${res.status})`); + return data; +} + +function toast(message, { error = false, link } = {}) { + const el = h("div", { class: `toast${error ? " error" : ""}`, role: error ? "alert" : "status" }, message); + if (link) el.append(" ", h("a", { href: link, target: "_blank", rel: "noopener noreferrer" }, "View ↗")); + ui.toasts.append(el); + setTimeout(() => el.remove(), error ? 8000 : 5000); +} + +function relativeTime(iso) { + if (!iso) return "never"; + const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + const units = [ + ["year", 31536000], + ["month", 2592000], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ]; + for (const [unit, size] of units) { + if (Math.abs(seconds) >= size) { + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(-Math.round(seconds / size), unit); + } + } + return "just now"; +} + +function cleanTitle(title) { + return String(title ?? "") + .replace(/^\[External Plugin\]\s*:?\s*/i, "") + .trim(); +} + +function itemName(item) { + return item.review?.pluginName || cleanTitle(item.title) || `#${item.number}`; +} + +function signalPills(item) { + const pills = []; + for (const label of item.labels ?? []) { + const match = label.match(/^needs-review:(HIGH|MEDIUM|LOW)$/i); + if (match) { + const level = match[1].toUpperCase(); + pills.push(h("span", { class: `pill pill-${level.toLowerCase()}`, title: "AGT contributor risk" }, `AGT ${level}`)); + } else if (label === "external-plugin-canvas") { + pills.push(h("span", { class: "pill" }, "canvas")); + } + } + if (item.decision) { + pills.push(h("span", { class: `pill pill-${item.decision.kind}` }, item.decision.kind === "approve" ? "Approved" : "Rejected")); + } else if (item.reviewStatus === "queued") { + pills.push(h("span", { class: "pill pill-queued" }, "Queued for AI")); + } + if (item.manualColumn && item.review?.recommendation) { + pills.push(h("span", { class: "pill pill-ai", title: "AI recommendation (you moved this card)" }, `AI: ${RECOMMENDATION_LABELS[item.review.recommendation]}`)); + } + return pills; +} + +function matchesFilter(item) { + if (!filter) return true; + const haystack = [item.number, item.title, item.author, item.review?.pluginName, item.review?.repository] + .join(" ") + .toLowerCase(); + return haystack.includes(filter); +} + +function findItem(number) { + return board?.items.find((item) => item.number === number) ?? null; +} + +function pendingReviewCount() { + return board ? board.items.filter((item) => !item.decision && item.reviewStatus !== "reviewed").length : 0; +} + +// ---------- board ---------- + +function renderBoard() { + if (!board) return; + const open = board.items.filter((item) => !item.decision).length; + ui.meta.textContent = `${board.repo} · ${open} open · refreshed ${relativeTime(board.lastRefreshedAt)}`; + const pending = pendingReviewCount(); + ui.review.textContent = pending ? `Perform review (${pending})` : "Perform review"; + ui.review.disabled = pending === 0; + + const scrollPositions = new Map( + [...ui.board.querySelectorAll(".column")].map((col) => [col.dataset.column, col.querySelector(".column-cards").scrollTop]), + ); + + const columns = board.columns.map((column) => { + const items = board.items.filter((item) => item.column === column.id && matchesFilter(item)); + const cards = h( + "div", + { class: "column-cards" }, + items.length ? items.map(renderCard) : h("p", { class: "empty" }, filter ? "No matches" : "Empty"), + ); + const col = h( + "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)), + cards, + ); + if (column.droppable) wireDropTarget(col, column.id); + return col; + }); + ui.board.replaceChildren(...columns); + + for (const col of ui.board.querySelectorAll(".column")) { + col.querySelector(".column-cards").scrollTop = scrollPositions.get(col.dataset.column) ?? 0; + } +} + +function renderCard(item) { + const draggable = !item.decision; + const card = h( + "article", + { + class: `card${selected === item.number ? " selected" : ""}`, + tabindex: "0", + draggable: draggable ? "true" : "false", + dataset: { number: item.number }, + "aria-label": `#${item.number} ${itemName(item)}`, + onclick: () => openDrawer(item.number), + onkeydown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDrawer(item.number); + } + }, + }, + h("div", { class: "card-top" }, h("span", { class: "card-number" }, `#${item.number}`), h("span", { class: "card-byline" }, `@${item.author} · ${relativeTime(item.createdAt)}`)), + h("p", { class: "card-title" }, itemName(item)), + item.review?.rationale ? h("p", { class: "card-rationale" }, item.review.rationale) : null, + ); + const pills = signalPills(item); + if (pills.length) card.append(h("div", { class: "pills" }, pills)); + if (draggable) { + card.addEventListener("dragstart", (event) => { + event.dataTransfer.setData("text/plain", String(item.number)); + event.dataTransfer.effectAllowed = "move"; + card.classList.add("dragging"); + }); + card.addEventListener("dragend", () => card.classList.remove("dragging")); + } + return card; +} + +function wireDropTarget(col, columnId) { + let depth = 0; + col.addEventListener("dragenter", (event) => { + event.preventDefault(); + depth += 1; + col.classList.add("drop-target"); + }); + col.addEventListener("dragover", (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }); + col.addEventListener("dragleave", () => { + depth -= 1; + if (depth <= 0) { + depth = 0; + col.classList.remove("drop-target"); + } + }); + col.addEventListener("drop", (event) => { + event.preventDefault(); + depth = 0; + col.classList.remove("drop-target"); + const number = Number(event.dataTransfer.getData("text/plain")); + const item = findItem(number); + if (item && item.column !== columnId) moveItem(number, columnId); + }); +} + +async function moveItem(number, column) { + const hadReview = column === "unreviewed" && Boolean(findItem(number)?.review); + try { + await api("POST", "/api/move", { number, column }); + if (hadReview) toast(`Cleared the AI review for #${number}; it will be included in the next review.`); + } catch (error) { + toast(error.message, { error: true }); + renderBoard(); + } +} + +// ---------- drawer ---------- + +function openDrawer(number) { + selected = number; + resetConfirm(); + hideRereview(); + const item = findItem(number); + ui.comment.value = defaultComment(item); + ui.drawer.classList.add("open"); + ui.drawer.setAttribute("aria-hidden", "false"); + renderDrawer(); + renderBoard(); + ui.drawerClose.focus({ preventScroll: true }); + if (activeTab !== "review") loadDetail(number); +} + +function closeDrawer() { + const previous = selected; + selected = null; + resetConfirm(); + hideRereview(); + ui.drawer.classList.remove("open"); + ui.drawer.setAttribute("aria-hidden", "true"); + renderBoard(); + ui.board.querySelector(`.card[data-number="${previous}"]`)?.focus({ preventScroll: true }); +} + +function defaultComment(item) { + const suggested = item?.review?.suggestedComment ?? ""; + return suggested.replace(/^\s*\/(approve|reject)\b\s*/i, "").trim(); +} + +function renderDrawer() { + const item = findItem(selected); + if (!item) { + if (selected != null) closeDrawer(); + return; + } + ui.drawerKicker.textContent = `#${item.number} · @${item.author}`; + ui.drawerTitle.textContent = itemName(item); + ui.drawerMeta.textContent = `Submitted ${relativeTime(item.createdAt)} · ${item.labels.join(", ")}`; + ui.drawerLink.href = item.url; + + const options = board.columns + .filter((column) => column.droppable || column.id === item.column) + .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 = Boolean(item.decision) || item.reviewStatus === "queued"; + if (ui.drawerRereview.disabled) hideRereview(); + + 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; + tab.textContent = count != null ? `Comments (${count})` : "Comments"; + } + } + + ui.drawerFooter.hidden = Boolean(item.decision); + updatePreview(); + renderTab(item); +} + +function renderTab(item) { + if (activeTab === "review") { + ui.drawerBody.replaceChildren(renderReview(item)); + return; + } + const detail = details.get(item.number); + if (!detail) { + ui.drawerBody.replaceChildren(h("p", { class: "notice" }, "Loading issue from GitHub…")); + return; + } + if (detail.error) { + ui.drawerBody.replaceChildren(h("p", { class: "notice" }, `Couldn't load issue: ${detail.error}`)); + return; + } + if (activeTab === "issue") { + ui.drawerBody.replaceChildren(markdownBlock(detail.bodyHtml)); + } else { + ui.drawerBody.replaceChildren( + ...(detail.comments.length ? detail.comments.map(renderComment) : [h("p", { class: "notice" }, "No comments yet.")]), + ); + } +} + +function renderReview(item) { + const wrapper = h("div"); + if (item.decision) { + wrapper.append( + h( + "p", + { class: "notice" }, + `${item.decision.kind === "approve" ? "Approved" : "Rejected"} ${relativeTime(item.decision.at)}: `, + h("code", {}, item.decision.body), + " ", + item.decision.commentUrl ? h("a", { href: item.decision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗") : null, + ), + ); + } + const review = item.review; + if (item.reviewStatus === "queued" && review) { + wrapper.append(h("p", { class: "notice" }, `Re-review in progress (started ${relativeTime(item.queuedAt)}). The result below will be replaced when it finishes.`)); + } + if (item.reviewStatus === "queued" && item.rereviewGuidance) { + wrapper.append(h("blockquote", { class: "guidance" }, h("strong", {}, "Your guidance: "), item.rereviewGuidance)); + } + if (!review) { + wrapper.append( + h( + "p", + { class: "notice" }, + item.reviewStatus === "queued" + ? `Queued for AI review ${relativeTime(item.queuedAt)}. Results appear here when the agent records them.` + : "No AI review yet. Use “Perform review” or “Re-review” to have the agent assess it.", + ), + ); + return wrapper; + } + const rows = [ + ["Recommendation", RECOMMENDATION_LABELS[review.recommendation] + (item.manualColumn ? ` (you moved it to ${RECOMMENDATION_LABELS[item.manualColumn]})` : "")], + ["Plugin", review.pluginName], + ["Repository", review.repository ? h("a", { href: `https://github.com/${review.repository}`, target: "_blank", rel: "noopener noreferrer" }, review.repository) : null], + ["Repo facts", review.repoFacts], + ["Signals", review.signals], + ["Contents", review.contents], + ["Commercial", review.commercial], + ["Rationale", review.rationale], + ["Check next", review.checkNext], + ["Suggested", review.suggestedComment ? h("code", {}, review.suggestedComment) : null], + ["Re-review focus", review.rereviewGuidance], + ["Reviewed", relativeTime(review.reviewedAt)], + ].filter(([, value]) => value); + wrapper.append(h("dl", { class: "review-grid" }, rows.flatMap(([label, value]) => [h("dt", {}, label), h("dd", {}, value)]))); + return wrapper; +} + +function markdownBlock(html) { + const block = h("div", { class: "markdown" }); + block.innerHTML = html || "

No description.

"; + for (const link of block.querySelectorAll("a[href]")) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + return block; +} + +function renderComment(comment) { + const isBot = comment.authorType === "Bot" || /\[bot\]$/.test(comment.author ?? "") || comment.author === "github-actions"; + const body = markdownBlock(comment.bodyHtml); + const el = h( + "article", + { class: `comment${isBot ? " collapsed" : ""}` }, + h( + "div", + { class: "comment-header" }, + h("strong", {}, comment.author ?? "unknown"), + comment.association && comment.association !== "NONE" ? h("span", { class: "pill" }, comment.association.toLowerCase()) : null, + isBot ? h("span", { class: "pill" }, "bot") : null, + h("a", { href: comment.url, target: "_blank", rel: "noopener noreferrer" }, relativeTime(comment.createdAt)), + ), + body, + ); + if (isBot) { + const toggle = h("button", { type: "button", class: "btn btn-small comment-toggle" }, "Expand"); + toggle.addEventListener("click", () => { + const collapsed = el.classList.toggle("collapsed"); + toggle.textContent = collapsed ? "Expand" : "Collapse"; + }); + el.querySelector(".comment-header").append(toggle); + } + return el; +} + +async function loadDetail(number, { force = false } = {}) { + if (!force && details.has(number) && !details.get(number).error) return; + details.delete(number); + if (selected === number) renderDrawer(); + try { + const data = await api(force ? "POST" : "GET", `/api/issue/${number}`); + details.set(number, data); + } catch (error) { + details.set(number, { error: error.message }); + } + if (selected === number) renderDrawer(); +} + +// ---------- decisions ---------- + +function commentBody(kind) { + const text = ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, ""); + if (kind === "approve") return text ? `/approve\n\n${text}` : "/approve"; + return text ? `/reject ${text}` : "/reject"; +} + +function updatePreview() { + const kind = pendingConfirm.kind; + ui.preview.textContent = kind + ? `Will post:\n${commentBody(kind)}` + : `Reject posts: ${commentBody("reject")}\nApprove posts: ${commentBody("approve").replace(/\n+/g, " ⏎ ")}`; +} + +function resetConfirm() { + clearTimeout(pendingConfirm.timer); + pendingConfirm.kind = null; + ui.approve.textContent = "Approve"; + ui.reject.textContent = "Reject"; + ui.approve.classList.remove("confirming"); + ui.reject.classList.remove("confirming"); + ui.approve.disabled = false; + ui.reject.disabled = false; + updatePreview(); +} + +async function decide(kind) { + const item = findItem(selected); + if (!item) return; + if (pendingConfirm.kind !== kind) { + resetConfirm(); + pendingConfirm.kind = kind; + const button = kind === "approve" ? ui.approve : ui.reject; + button.textContent = kind === "approve" ? "Confirm approve" : "Confirm reject"; + button.classList.add("confirming"); + updatePreview(); + pendingConfirm.timer = setTimeout(resetConfirm, 6000); + return; + } + clearTimeout(pendingConfirm.timer); + ui.approve.disabled = true; + ui.reject.disabled = true; + try { + const result = await api("POST", "/api/decision", { number: item.number, kind, comment: ui.comment.value }); + toast(`Posted ${result.body.split("\n")[0]} on #${item.number}`, { link: result.commentUrl }); + } catch (error) { + toast(error.message, { error: true }); + } finally { + resetConfirm(); + } +} + +// ---------- toolbar ---------- + +async function withBusy(button, label, fn) { + const original = button.textContent; + button.disabled = true; + button.textContent = label; + try { + return await fn(); + } finally { + button.textContent = original; + button.disabled = false; + renderBoard(); + } +} + +ui.refresh.addEventListener("click", () => + withBusy(ui.refresh, "Refreshing…", async () => { + try { + const result = await api("POST", "/api/refresh"); + const parts = [`${result.total} open`]; + if (result.added.length) parts.push(`${result.added.length} new`); + if (result.removed.length) parts.push(`${result.removed.length} removed`); + toast(`Refreshed: ${parts.join(", ")}`); + } catch (error) { + toast(error.message, { error: true }); + } + }), +); + +ui.review.addEventListener("click", () => + withBusy(ui.review, "Starting…", async () => { + try { + const result = await api("POST", "/api/review", {}); + toast(result.message); + } catch (error) { + toast(error.message, { error: true }); + } + }), +); + +ui.search.addEventListener("input", () => { + filter = ui.search.value.trim().toLowerCase(); + renderBoard(); +}); + +ui.drawerClose.addEventListener("click", closeDrawer); +ui.drawerMove.addEventListener("change", () => moveItem(selected, ui.drawerMove.value)); +function hideRereview() { + ui.rereviewPanel.hidden = true; + ui.rereviewGuidance.value = ""; + ui.drawerRereview.setAttribute("aria-expanded", "false"); +} + +ui.drawerRereview.setAttribute("aria-controls", "rereview-panel"); +ui.drawerRereview.addEventListener("click", () => { + if (!ui.rereviewPanel.hidden) { + hideRereview(); + return; + } + ui.rereviewPanel.hidden = false; + ui.drawerRereview.setAttribute("aria-expanded", "true"); + ui.rereviewGuidance.focus(); +}); +ui.rereviewCancel.addEventListener("click", () => { + hideRereview(); + ui.drawerRereview.focus(); +}); +ui.rereviewGuidance.addEventListener("keydown", (event) => { + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + ui.rereviewPanel.requestSubmit(); + } else if (event.key === "Escape") { + event.stopPropagation(); + hideRereview(); + ui.drawerRereview.focus(); + } +}); +ui.rereviewPanel.addEventListener("submit", async (event) => { + event.preventDefault(); + const number = selected; + if (number == null) return; + ui.rereviewStart.disabled = true; + try { + const result = await api("POST", "/api/rereview", { number, guidance: ui.rereviewGuidance.value }); + toast(result.message); + hideRereview(); + } catch (error) { + toast(error.message, { error: true }); + } finally { + ui.rereviewStart.disabled = false; + } +}); + +for (const tab of ui.tabs) { + tab.addEventListener("click", () => { + activeTab = tab.dataset.tab; + renderDrawer(); + if (activeTab !== "review" && selected != null) loadDetail(selected); + }); +} + +ui.comment.addEventListener("input", () => { + if (pendingConfirm.kind) resetConfirm(); + else updatePreview(); +}); +ui.approve.addEventListener("click", () => decide("approve")); +ui.reject.addEventListener("click", () => decide("reject")); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && selected != null) { + if (pendingConfirm.kind) resetConfirm(); + else closeDrawer(); + } +}); + +// ---------- live updates ---------- + +function connect() { + const events = new EventSource(`/events?token=${encodeURIComponent(token)}`); + events.addEventListener("board", (event) => { + board = JSON.parse(event.data); + renderBoard(); + if (selected != null) renderDrawer(); + }); + events.addEventListener("error", () => { + ui.meta.textContent = "Reconnecting…"; + }); +} + +connect(); +setInterval(() => board && renderBoard(), 60000); diff --git a/.github/extensions/external-plugin-review-board/public/index.html b/.github/extensions/external-plugin-review-board/public/index.html new file mode 100644 index 0000000000..20d40ffd05 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/public/index.html @@ -0,0 +1,79 @@ + + + + + + + Plugin review board + + + +
+
+

Plugin review board

+

Loading…

+
+
+ + + +
+
+ +
+ + + +
+ + + diff --git a/.github/extensions/external-plugin-review-board/public/styles.css b/.github/extensions/external-plugin-review-board/public/styles.css new file mode 100644 index 0000000000..a29dd48902 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/public/styles.css @@ -0,0 +1,675 @@ +:root { + --bg: var(--background-color-default, #ffffff); + --fg: var(--text-color-default, #1f2328); + --muted: var(--text-color-muted, #59636e); + --border: var(--border-color-default, #d1d9e0); + --focus: var(--color-focus-outline, #0969da); + --surface: color-mix(in srgb, var(--fg) 4%, var(--bg)); + --surface-raised: color-mix(in srgb, var(--fg) 2%, var(--bg)); + --red: var(--true-color-red, #cf222e); + --red-muted: var(--true-color-red-muted, color-mix(in srgb, var(--red) 14%, var(--bg))); + --orange: var(--true-color-orange, #bc4c00); + --orange-muted: var(--true-color-orange-muted, color-mix(in srgb, var(--orange) 14%, var(--bg))); + --yellow: var(--true-color-yellow, #9a6700); + --yellow-muted: var(--true-color-yellow-muted, color-mix(in srgb, var(--yellow) 14%, var(--bg))); + --green: var(--true-color-green, #1a7f37); + --green-muted: var(--true-color-green-muted, color-mix(in srgb, var(--green) 14%, var(--bg))); + --blue: var(--true-color-blue, #0969da); + --blue-muted: var(--true-color-blue-muted, color-mix(in srgb, var(--blue) 14%, var(--bg))); + --purple: var(--true-color-purple, #8250df); + --purple-muted: var(--true-color-purple-muted, color-mix(in srgb, var(--purple) 14%, var(--bg))); + --radius: 8px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); +} + +h1, +h2 { + margin: 0; + font-weight: var(--font-weight-semibold, 600); +} + +h1 { + font-size: var(--text-title-small, 16px); + line-height: var(--leading-title-small, 24px); +} + +h2 { + font-size: var(--text-title-small, 16px); + line-height: 1.35; +} + +.meta { + margin: 0; + color: var(--muted); + font-size: var(--text-body-small, 12px); + line-height: var(--leading-body-small, 16px); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +/* Toolbar */ +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.search input { + width: 220px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: inherit; + font: inherit; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-raised); + color: inherit; + font: inherit; + font-weight: var(--font-weight-semibold, 600); + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { + background: var(--surface); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + border-color: transparent; + background: var(--blue); + color: var(--color-white, #fff); +} + +.btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--blue) 88%, black); +} + +.btn-danger { + color: var(--red); +} + +.btn-danger.confirming { + border-color: transparent; + background: var(--red); + color: var(--color-white, #fff); +} + +.btn-success { + color: var(--green); +} + +.btn-success.confirming { + border-color: transparent; + background: var(--green); + color: var(--color-white, #fff); +} + +.btn-small { + padding: 3px 8px; + font-size: var(--text-body-small, 12px); +} + +.btn-icon { + padding: 2px 8px; + border-color: transparent; + background: transparent; +} + +/* Board */ +.board { + flex: 1; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(250px, 1fr); + gap: 12px; + padding: 12px 16px 16px; + overflow-x: auto; + min-height: 0; +} + +.column { + display: flex; + flex-direction: column; + min-height: 0; + border-radius: var(--radius); + background: var(--surface); +} + +.column[data-column="unreviewed"] { + --accent: var(--muted); +} +.column[data-column="reviewing"] { + --accent: var(--purple); +} +.column[data-column="straight-reject"] { + --accent: var(--red); +} +.column[data-column="probably-reject"] { + --accent: var(--orange); +} +.column[data-column="needs-review"] { + --accent: var(--yellow); +} +.column[data-column="accept"] { + --accent: var(--green); +} +.column[data-column="actioned"] { + --accent: var(--blue); +} + +.column-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 6px; + font-weight: var(--font-weight-semibold, 600); +} + +.column-name { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.column-name::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent, var(--border)); +} + +.count { + min-width: 22px; + padding: 0 7px; + border-radius: 999px; + background: var(--bg); + color: var(--muted); + font-size: var(--text-body-small, 12px); + text-align: center; +} + +.column-cards { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 8px 10px; + overflow-y: auto; + border-radius: 0 0 var(--radius) var(--radius); + transition: background-color 120ms ease-out; +} + +.column.drop-target .column-cards { + background: color-mix(in srgb, var(--accent) 12%, transparent); + outline: 2px dashed var(--accent); + outline-offset: -4px; +} + +.empty { + margin: 12px 4px; + color: var(--muted); + font-size: var(--text-body-small, 12px); + text-align: center; +} + +/* Cards */ +.card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + cursor: pointer; + transition: + border-color 120ms ease-out, + box-shadow 120ms ease-out; +} + +.card:hover { + border-color: color-mix(in srgb, var(--accent, var(--focus)) 60%, var(--border)); +} + +.card.selected { + border-color: var(--focus); + box-shadow: 0 0 0 1px var(--focus); +} + +.card.dragging { + opacity: 0.5; +} + +.card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + color: var(--muted); + font-size: var(--text-body-small, 12px); +} + +.card-number { + flex-shrink: 0; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.card-byline { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-title { + margin: 0; + font-weight: var(--font-weight-semibold, 600); + overflow-wrap: anywhere; +} + +.card-rationale { + margin: 0; + color: var(--muted); + font-size: var(--text-body-small, 12px); + line-height: var(--leading-body-small, 16px); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.pills { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.pill { + padding: 0 6px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +.pill-high { + border-color: transparent; + background: var(--red-muted); + color: var(--red); +} +.pill-medium { + border-color: transparent; + background: var(--yellow-muted); + color: var(--yellow); +} +.pill-ai { + border-color: transparent; + background: var(--purple-muted); + color: var(--purple); +} +.pill-approve { + border-color: transparent; + background: var(--green-muted); + color: var(--green); +} +.pill-reject { + border-color: transparent; + background: var(--red-muted); + color: var(--red); +} +.pill-queued { + border-color: transparent; + background: var(--purple-muted); + color: var(--purple); +} + +/* Drawer */ +.drawer { + position: fixed; + inset: 0 0 0 auto; + width: min(640px, 100%); + display: flex; + flex-direction: column; + border-left: 1px solid var(--border); + background: var(--bg); + box-shadow: -12px 0 32px color-mix(in srgb, black 18%, transparent); + transform: translateX(100%); + visibility: hidden; + transition: + transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1), + visibility 0s linear 200ms; + z-index: 10; +} + +.drawer.open { + transform: none; + visibility: visible; + transition: + transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1), + visibility 0s; +} + +@media (prefers-reduced-motion: reduce) { + .drawer, + .drawer.open { + transition: none; + } +} + +.drawer-header { + display: flex; + gap: 12px; + justify-content: space-between; + padding: 14px 16px 8px; +} + +.drawer-kicker { + margin: 0 0 2px; + color: var(--muted); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-body-small, 12px); +} + +.drawer-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 0 16px 10px; + font-size: var(--text-body-small, 12px); +} + +.drawer-controls select { + margin-left: 4px; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: inherit; + font: inherit; +} + +.tabs { + display: flex; + gap: 4px; + padding: 0 12px; + border-bottom: 1px solid var(--border); +} + +.tab { + padding: 8px 10px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--muted); + font: inherit; + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; +} + +.tab[aria-selected="true"] { + border-bottom-color: var(--focus); + color: var(--fg); +} + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 16px; + min-height: 0; +} + +.review-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px 16px; + margin: 0; +} + +.review-grid dt { + color: var(--muted); + font-weight: var(--font-weight-semibold, 600); +} + +.review-grid dd { + margin: 0; + overflow-wrap: anywhere; +} + +.review-grid code, +.markdown code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: var(--text-code-inline, 12px); + padding: 1px 4px; + border-radius: 4px; + background: var(--surface); +} + +.rereview-panel { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 16px 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-raised); +} + +.rereview-panel[hidden] { + display: none; +} + +.rereview-panel .meta { + margin: 0; +} + +.guidance { + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + white-space: pre-wrap; +} + +.notice { + padding: 12px; + border-radius: var(--radius); + background: var(--surface); + color: var(--muted); +} + +.comment { + margin-bottom: 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.comment-header { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface); + font-size: var(--text-body-small, 12px); +} + +.comment-header a { + color: var(--muted); +} + +.comment.collapsed .markdown { + max-height: 160px; + overflow: hidden; + mask-image: linear-gradient(to bottom, black 60%, transparent); +} + +.comment-toggle { + margin-left: auto; +} + +.markdown { + padding: 8px 12px; + overflow-wrap: anywhere; +} + +.markdown img { + max-width: 100%; +} + +.markdown pre { + overflow-x: auto; + padding: 8px; + border-radius: 6px; + background: var(--surface); + font-size: var(--text-code-inline, 12px); +} + +.markdown table { + border-collapse: collapse; + display: block; + overflow-x: auto; +} + +.markdown th, +.markdown td { + padding: 4px 8px; + border: 1px solid var(--border); +} + +.markdown a { + color: var(--blue); +} + +.drawer-footer { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 16px 14px; + border-top: 1px solid var(--border); + background: var(--surface-raised); +} + +.footer-label { + font-size: var(--text-body-small, 12px); + font-weight: var(--font-weight-semibold, 600); +} + +.drawer-footer textarea, +.rereview-panel textarea { + width: 100%; + resize: vertical; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: inherit; + font: inherit; +} + +.preview { + margin: 0; + color: var(--muted); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.footer-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* Toasts */ +.toasts { + position: fixed; + bottom: 16px; + left: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 20; +} + +.toast { + max-width: 420px; + padding: 8px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + box-shadow: 0 6px 20px color-mix(in srgb, black 15%, transparent); +} + +.toast.error { + border-color: var(--red); + color: var(--red); +} + +.toast a { + color: var(--blue); +} diff --git a/.github/extensions/external-plugin-review-board/review-guidance.md b/.github/extensions/external-plugin-review-board/review-guidance.md new file mode 100644 index 0000000000..d2322f4c33 --- /dev/null +++ b/.github/extensions/external-plugin-review-board/review-guidance.md @@ -0,0 +1,67 @@ +# External plugin review guidance + +Guidance used by the External Plugin Review Board canvas when the agent performs an AI review of +`external-plugin` + `ready-for-review` submissions in github/awesome-copilot. Edit this file to tune +how submissions are triaged. + +## Maintainer decision pattern + +Baseline derived from ~47 maintainer decisions (Aug–Sep 2026). Refresh this with recent closed +submissions before reviewing (see "Calibrate" below). + +**Typically approved** + +- Established vendors or well-known OSS projects with genuine developer-workflow value + (e.g. Sumo Logic, Atlassian Teamwork Graph CLI, bitdrift, Remotion, Radius, incident.io-style SRE tooling). +- Genuinely useful Copilot tooling and canvases that don't duplicate built-in features + (e.g. excel-cli/excel-mcp, chat-fork-map, cache-stats, usage-insights). + +**Typically rejected (with the maintainer's usual wording)** + +- Purely paid SaaS / paywalled connectors — "Sorry, we're not a channel for purely paid services." or + "This appears to be a narrow paid SaaS connector rather than a broadly useful Copilot plugin." +- Marketing pitches for brand-new products, often vibe-coded dumps (repo days old, 0 stars, few commits) — + "This is primarily a marketing push for an external service." +- Crypto / payments / finance niches, or otherwise niche problem spaces — "This is not a fit for the repo." + / "a bit too niche a problem space for this repo." +- Duplicates of built-in Copilot features: memory ("Copilot has a built-in memory system"), tool-use guards + ("Copilot harnesses already have built-in provisions for controlling tool use"), markdown viewing, browser + automation ("This can be done using Playwright, which is available in the Copilot harnesses"). +- Opinionated personal workflows or frameworks — "too specific towards a style of working." +- Product-specific and narrow — "too product-specific and narrowly scoped for this repo at this time." +- Self-promotional or overly complex skills — "heavily geared towards self-promotion ... overly complex". +- Bulk submissions of many tiny related repos from one submitter. +- Resubmissions: check whether the reasons for the previous rejection were actually addressed. + +## Procedure per issue + +1. `gh issue view -R github/awesome-copilot --json title,body,author,comments` — read the submission + form (repo, path, sha, description, author/homepage URLs, notes) and the automated comments: + "Reviewer signals" table (repo age, stars/forks), quality gate results and warnings, the AGT + "Contributor Reputation Check" (HIGH/MEDIUM risk), pricing/checkout homepage heuristics, and any + "Warnings" section. Note any maintainer comments. +2. Inspect the plugin repo at the submitted SHA (read-only), e.g. `gh api repos/OWNER/REPO`, + `gh api repos/OWNER/REPO/git/trees/SHA?recursive=1`, `gh api repos/OWNER/REPO/contents/PATH?ref=SHA`, + `gh api repos/OWNER/REPO/commits?per_page=10`. Determine what it contains (skills, agents, MCP servers, + hooks, canvases, scripts), content size/quality, whether it's a thin wrapper around a paid/hosted + service, whether the repo is brand new, whether the submitter account is new, and whether it duplicates + built-in Copilot features. +3. Optionally fetch the homepage to check for pricing / paid-only access. +4. Never comment on, label, or modify issues or repos while reviewing. + +## Calibrate + +Before a large batch, glance at recent maintainer decisions to see if the pattern has shifted: + +``` +gh issue list -R github/awesome-copilot --label external-plugin --state closed --limit 40 --json number,title,labels +``` + +and read the `/approve` / `/reject` comments on the most recent ones. + +## Recommendation buckets + +- `straight-reject` — clearly matches a rejection pattern. +- `probably-reject` — likely reject, but something specific deserves a quick maintainer look. +- `needs-review` — genuine judgement call. +- `accept` — fits the approval pattern. diff --git a/.gitignore b/.gitignore index d2a8cb4848..e4c6d22e84 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ obj/ bin/ .impeccable .playwright-mcp + +# External plugin review board canvas state (local only) +.github/extensions/external-plugin-review-board/state/ From ea001a923bc22dc62beaea40e5aef02e1bae9a28 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 29 Sep 2026 16:35:48 +1000 Subject: [PATCH 2/3] fix: harden review board per PR feedback - Deny framing and hide stack traces from API error responses - Track review queue ids, restore queue state on send failure, skip queued items - Require reject reasons; treat posted decisions as pending until the workflow closes the issue - Invalidate browser issue cache on refresh - Clarify paid-services guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extension.mjs | 81 ++++++++++++++++--- .../lib/review-prompt.mjs | 11 ++- .../lib/server.mjs | 40 ++++++++- .../lib/state.mjs | 66 ++++++++++++++- .../public/app.js | 37 ++++++++- .../review-guidance.md | 6 +- 6 files changed, 220 insertions(+), 21 deletions(-) diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 79462d1025..6d29ac0db1 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -9,7 +9,7 @@ import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/exte 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 } from "./lib/server.mjs"; +import { startBoardServer, expectedError } from "./lib/server.mjs"; const CANVAS_ID = "external-plugin-review-board"; const REPO = "github/awesome-copilot"; @@ -24,9 +24,31 @@ 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); @@ -49,7 +71,7 @@ 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); - await store.markQueued(queued); + const snapshots = await store.markQueued(queued); const prompt = buildReviewPrompt({ items, instanceId, @@ -58,7 +80,12 @@ async function startReview({ numbers, instanceId, send }) { history: store.state.history, }); if (send) { - await session.send({ prompt }); + 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 }; @@ -71,6 +98,7 @@ async function startRereview({ number, guidance, instanceId, send }) { 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, @@ -80,9 +108,13 @@ async function startRereview({ number, guidance, instanceId, send }) { history: store.state.history, repo: store.state.repo, }); - await store.markQueued([item.number], { rereviewGuidance: text || null }); if (send) { - await session.send({ prompt }); + 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 }; @@ -107,12 +139,18 @@ async function postDecision({ number, kind, comment }) { } 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 decision = { kind, comment: text, body, commentUrl, at: new Date().toISOString() }; - await store.recordDecision(number, decision); + 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, ...decision }; + return { number, ...pendingDecision, pending: true }; } function boardSummary() { @@ -132,12 +170,15 @@ function boardSummary() { 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) { - return { + const routes = { "GET /api/board": () => store.snapshot(), "POST /api/refresh": () => refresh(), "POST /api/review": ({ body }) => startReview({ numbers: body.numbers, instanceId, send: true }), @@ -149,6 +190,24 @@ function routesFor(instanceId) { "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) { @@ -281,6 +340,10 @@ session = await joinSession({ 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); diff --git a/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs b/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs index 178b2a8ab0..efff58cc19 100644 --- a/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs +++ b/.github/extensions/external-plugin-review-board/lib/review-prompt.mjs @@ -4,6 +4,7 @@ export const REVIEW_FIELDS_SCHEMA = { required: ["number", "recommendation", "rationale"], properties: { number: { type: "integer", description: "Issue number." }, + queueId: { type: "string", description: "Review token from the queued board card." }, recommendation: { type: "string", enum: ["straight-reject", "probably-reject", "needs-review", "accept"], @@ -25,7 +26,7 @@ export const REVIEW_FIELDS_SCHEMA = { }; const REVIEW_FIELD_LIST = - "number, recommendation (straight-reject | probably-reject | needs-review | accept), pluginName, repository, repoFacts, signals, contents, commercial, rationale, checkNext (optional), suggestedComment (e.g. \"/reject \" or \"/approve\")"; + "number, queueId (copy the reviewToken exactly), recommendation (straight-reject | probably-reject | needs-review | accept), pluginName, repository, repoFacts, signals, contents, commercial, rationale, checkNext (optional), suggestedComment (e.g. \"/reject \" or \"/approve\")"; function formatHistory(history) { return history.length @@ -52,6 +53,7 @@ export function buildRereviewPrompt({ item, guidance, instanceId, canvasId, guid : "The maintainer didn't give specific guidance; take a fresh, independent look."; const childPrompt = `Re-review external plugin submission #${item.number} ("${item.title}" by @${item.author}) in ${repo}. +Review token: ${item.queueId} Read the review guidance first (use the view tool, absolute path): ${guidancePath} Follow its per-issue procedure. Work strictly read-only: do NOT comment on, label, close, or modify any issue or repository, and do not edit files. @@ -64,7 +66,7 @@ ${previous} Recent maintainer decisions (calibrate against these as well as the guidance): ${formatHistory(history)} -When you're done, send your result back to the session that created you (your creator) as a single message containing ONLY a JSON object with these fields: ${REVIEW_FIELD_LIST}. In the rationale, explicitly address the maintainer's guidance.`; +When you're done, send your result back to the session that created you (your creator) as a single message containing ONLY a JSON object with these fields: ${REVIEW_FIELD_LIST}. Include queueId: "${item.queueId}". In the rationale, explicitly address the maintainer's guidance.`; return `The maintainer requested a guided re-review of #${item.number} from the External Plugin Review Board canvas. Run it as a separate sub-session; do NOT perform the review yourself in this session. @@ -79,7 +81,10 @@ ${childPrompt} export function buildReviewPrompt({ items, instanceId, canvasId, guidancePath, history }) { const list = items - .map((item) => `- #${item.number} ${item.title} (by @${item.author}; labels: ${item.labels.join(", ") || "none"})`) + .map( + (item) => + `- #${item.number} ${item.title} (reviewToken: ${item.queueId}; by @${item.author}; labels: ${item.labels.join(", ") || "none"})`, + ) .join("\n"); const recent = formatHistory(history); diff --git a/.github/extensions/external-plugin-review-board/lib/server.mjs b/.github/extensions/external-plugin-review-board/lib/server.mjs index 9abb2b176b..bfe3675548 100644 --- a/.github/extensions/external-plugin-review-board/lib/server.mjs +++ b/.github/extensions/external-plugin-review-board/lib/server.mjs @@ -15,11 +15,20 @@ const CSP = [ "img-src 'self' https: data:", "media-src https:", "connect-src 'self'", - "frame-ancestors *", + "base-uri 'none'", + "form-action 'none'", + "object-src 'none'", + // The Copilot host loads canvases as a top-level webview (see extensions/sentry-triage/server.mjs), + // so framing is never legitimate and denying it blocks clickjacking of the approve/reject controls. + "frame-ancestors 'none'", ].join("; "); const MAX_BODY = 64 * 1024; +export function expectedError(message, status) { + return Object.assign(new Error(message), { publicMessage: message, status }); +} + function readJson(req) { return new Promise((resolve, reject) => { let size = 0; @@ -27,7 +36,7 @@ function readJson(req) { req.on("data", (chunk) => { size += chunk.length; if (size > MAX_BODY) { - reject(Object.assign(new Error("Request body too large"), { status: 413 })); + reject(expectedError("Request body too large", 413)); req.destroy(); return; } @@ -38,7 +47,7 @@ function readJson(req) { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { - reject(Object.assign(new Error("Invalid JSON body"), { status: 400 })); + reject(expectedError("Invalid JSON body", 400)); } }); req.on("error", reject); @@ -50,6 +59,19 @@ function sendJson(res, status, payload) { res.end(JSON.stringify(payload)); } +function reportError(api, error) { + const logger = api.logger ?? console; + if (typeof logger.error === "function") { + logger.error("External plugin review board server error", error); + } +} + +function clientErrorMessage(error) { + const status = Number.isInteger(error?.status) ? error.status : 500; + if (status >= 400 && status < 500) return error?.publicMessage || "Request failed"; + return "Internal server error"; +} + /** * Starts a loopback server for one canvas instance. * `api` provides: snapshot(), subscribe(fn) => unsubscribe, and route handlers keyed by "METHOD /path". @@ -78,6 +100,7 @@ export async function startBoardServer({ publicDir, api }) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": CSP, + "X-Frame-Options": "DENY", "Cache-Control": "no-store", }); res.end(html.replace("__BOARD_TOKEN__", token)); @@ -129,7 +152,16 @@ export async function startBoardServer({ publicDir, api }) { const result = await handler({ body, number: numberMatch ? Number(numberMatch[1]) : undefined }); sendJson(res, 200, result ?? {}); } catch (error) { - sendJson(res, error.status ?? 500, { error: error.message ?? String(error) }); + if (!res.headersSent) { + const status = Number.isInteger(error?.status) ? error.status : 500; + if (status >= 500) reportError(api, error); + sendJson(res, status, { error: clientErrorMessage(error) }); + } else { + reportError(api, error); + try { + res.end(); + } catch {} + } } }); diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index 5ef5f7ccf5..091a2bebe1 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -1,4 +1,5 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; import path from "node:path"; export const RECOMMENDATIONS = ["straight-reject", "probably-reject", "needs-review", "accept"]; @@ -15,6 +16,10 @@ export const COLUMNS = [ const HISTORY_LIMIT = 200; +function newQueueId() { + return randomBytes(6).toString("base64url"); +} + function emptyState(repo) { return { version: 1, repo, lastRefreshedAt: null, items: {}, history: [] }; } @@ -131,7 +136,7 @@ export class BoardStore { } pendingReview(numbers) { - const all = Object.values(this.state.items).filter((item) => !item.decision); + const all = Object.values(this.state.items).filter((item) => !item.decision && item.reviewStatus !== "queued"); if (numbers?.length) { const wanted = new Set(numbers.map(Number)); return all.filter((item) => wanted.has(item.number)); @@ -141,14 +146,42 @@ export class BoardStore { async markQueued(numbers, { rereviewGuidance } = {}) { const now = new Date().toISOString(); + const snapshots = []; for (const number of numbers) { const item = this.getItem(number); + snapshots.push({ + number, + previous: { + reviewStatus: item.reviewStatus, + queuedAt: item.queuedAt, + queueId: item.queueId, + rereviewGuidance: item.rereviewGuidance, + }, + }); item.reviewStatus = "queued"; item.queuedAt = now; + item.queueId = newQueueId(); if (rereviewGuidance !== undefined) item.rereviewGuidance = rereviewGuidance; else delete item.rereviewGuidance; } await this.commit(); + return snapshots; + } + + async restoreQueueState(snapshots) { + for (const snapshot of snapshots) { + const item = this.state.items[String(snapshot.number)]; + if (!item) continue; + const { previous } = snapshot; + item.reviewStatus = previous.reviewStatus; + if (previous.queuedAt === undefined) delete item.queuedAt; + else item.queuedAt = previous.queuedAt; + if (previous.queueId === undefined) delete item.queueId; + else item.queueId = previous.queueId; + if (previous.rereviewGuidance === undefined) delete item.rereviewGuidance; + else item.rereviewGuidance = previous.rereviewGuidance; + } + await this.commit(); } async recordReviews(reviews) { @@ -164,11 +197,16 @@ export class BoardStore { skipped.push({ number: review.number, reason: `invalid recommendation ${review.recommendation}` }); continue; } + if (item.queueId && review.queueId !== item.queueId) { + skipped.push({ number: review.number, reason: "stale review result" }); + continue; + } const { number, ...rest } = review; item.review = { ...rest, reviewedAt: new Date().toISOString() }; item.reviewStatus = "reviewed"; item.manualColumn = null; delete item.queuedAt; + delete item.queueId; delete item.rereviewGuidance; recorded.push(number); } @@ -184,9 +222,11 @@ export class BoardStore { item.reviewStatus = "unreviewed"; item.manualColumn = null; delete item.queuedAt; + delete item.queueId; } else if (RECOMMENDATIONS.includes(column)) { item.manualColumn = item.review?.recommendation === column ? null : column; if (item.reviewStatus === "queued") item.reviewStatus = item.review ? "reviewed" : "unreviewed"; + delete item.queueId; delete item.rereviewGuidance; } else { throw new Error(`Items cannot be moved to "${column}".`); @@ -195,9 +235,33 @@ export class BoardStore { return { number, column: effectiveColumn(item) }; } + async recordPendingDecision(number, pendingDecision) { + const item = this.getItem(number); + item.pendingDecision = pendingDecision; + await this.commit(); + } + + async resolvePendingDecision(number, status) { + const item = this.state.items[String(number)]; + if (!item?.pendingDecision || status.state !== "CLOSED") return null; + const labels = new Set(status.labels ?? []); + const kind = labels.has("approved") ? "approve" : labels.has("rejected") ? "reject" : null; + if (!kind) return null; + const decision = { ...item.pendingDecision, kind, resolvedAt: new Date().toISOString() }; + await this.recordDecision(number, decision); + return decision; + } + + async pendingDecisionNumbers() { + return Object.values(this.state.items) + .filter((item) => item.pendingDecision && !item.decision) + .map((item) => item.number); + } + async recordDecision(number, decision) { const item = this.getItem(number); item.decision = decision; + delete item.pendingDecision; this.state.history.unshift({ number, title: item.title, diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js index 631f20378e..efcbeb7364 100644 --- a/.github/extensions/external-plugin-review-board/public/app.js +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -119,6 +119,8 @@ function signalPills(item) { } if (item.decision) { pills.push(h("span", { class: `pill pill-${item.decision.kind}` }, item.decision.kind === "approve" ? "Approved" : "Rejected")); + } else if (item.pendingDecision) { + pills.push(h("span", { class: "pill pill-queued" }, `${item.pendingDecision.kind === "approve" ? "Approve" : "Reject"} pending`)); } else if (item.reviewStatus === "queued") { pills.push(h("span", { class: "pill pill-queued" }, "Queued for AI")); } @@ -141,7 +143,9 @@ function findItem(number) { } function pendingReviewCount() { - return board ? board.items.filter((item) => !item.decision && item.reviewStatus !== "reviewed").length : 0; + return board + ? board.items.filter((item) => !item.decision && item.reviewStatus !== "reviewed" && item.reviewStatus !== "queued").length + : 0; } // ---------- board ---------- @@ -356,6 +360,19 @@ function renderReview(item) { ), ); } + if (item.pendingDecision && !item.decision) { + wrapper.append( + h( + "p", + { class: "notice" }, + `${item.pendingDecision.kind === "approve" ? "Approve" : "Reject"} command posted ${relativeTime(item.pendingDecision.at)}; waiting for GitHub Actions to close and label the issue. You can retry if the issue stays ready for review.`, + " ", + item.pendingDecision.commentUrl + ? h("a", { href: item.pendingDecision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗") + : null, + ), + ); + } const review = item.review; if (item.reviewStatus === "queued" && review) { wrapper.append(h("p", { class: "notice" }, `Re-review in progress (started ${relativeTime(item.queuedAt)}). The result below will be replaced when it finishes.`)); @@ -448,7 +465,7 @@ async function loadDetail(number, { force = false } = {}) { function commentBody(kind) { const text = ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, ""); if (kind === "approve") return text ? `/approve\n\n${text}` : "/approve"; - return text ? `/reject ${text}` : "/reject"; + return text ? `/reject ${text}` : "/reject "; } function updatePreview() { @@ -456,6 +473,8 @@ function updatePreview() { ui.preview.textContent = kind ? `Will post:\n${commentBody(kind)}` : `Reject posts: ${commentBody("reject")}\nApprove posts: ${commentBody("approve").replace(/\n+/g, " ⏎ ")}`; + ui.reject.disabled = !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, ""); + ui.reject.title = ui.reject.disabled ? "Reject requires a reason." : ""; } function resetConfirm() { @@ -473,6 +492,11 @@ function resetConfirm() { async function decide(kind) { const item = findItem(selected); if (!item) return; + if (kind === "reject" && !ui.comment.value.trim().replace(/^\/(approve|reject)\b\s*/i, "")) { + toast("Reject decisions require a reason.", { error: true }); + updatePreview(); + return; + } if (pendingConfirm.kind !== kind) { resetConfirm(); pendingConfirm.kind = kind; @@ -488,7 +512,12 @@ async function decide(kind) { ui.reject.disabled = true; try { const result = await api("POST", "/api/decision", { number: item.number, kind, comment: ui.comment.value }); - toast(`Posted ${result.body.split("\n")[0]} on #${item.number}`, { link: result.commentUrl }); + toast( + result.pending + ? `Posted ${result.body.split("\n")[0]} on #${item.number}; waiting for workflow confirmation.` + : `Posted ${result.body.split("\n")[0]} on #${item.number}`, + { link: result.commentUrl }, + ); } catch (error) { toast(error.message, { error: true }); } finally { @@ -515,10 +544,12 @@ ui.refresh.addEventListener("click", () => withBusy(ui.refresh, "Refreshing…", async () => { try { const result = await api("POST", "/api/refresh"); + details.clear(); const parts = [`${result.total} open`]; if (result.added.length) parts.push(`${result.added.length} new`); if (result.removed.length) parts.push(`${result.removed.length} removed`); toast(`Refreshed: ${parts.join(", ")}`); + if (selected != null && activeTab !== "review") loadDetail(selected, { force: true }); } catch (error) { toast(error.message, { error: true }); } diff --git a/.github/extensions/external-plugin-review-board/review-guidance.md b/.github/extensions/external-plugin-review-board/review-guidance.md index d2322f4c33..36697fa8ba 100644 --- a/.github/extensions/external-plugin-review-board/review-guidance.md +++ b/.github/extensions/external-plugin-review-board/review-guidance.md @@ -18,8 +18,12 @@ submissions before reviewing (see "Calibrate" below). **Typically rejected (with the maintainer's usual wording)** -- Purely paid SaaS / paywalled connectors — "Sorry, we're not a channel for purely paid services." or +- Thin connectors to paid services that offer little practical Copilot value beyond promoting the service + (no usable free path, narrow scope, sales-led framing) — "Sorry, we're not a channel for purely paid services." or "This appears to be a narrow paid SaaS connector rather than a broadly useful Copilot plugin." + Being paid or vendor-backed is not on its own a reason to reject: per the + [paid-services guidance](https://github.com/github/awesome-copilot/discussions/968), technically useful, + broadly relevant, neutrally framed plugins that are clear about their limitations are welcome. - Marketing pitches for brand-new products, often vibe-coded dumps (repo days old, 0 stars, few commits) — "This is primarily a marketing push for an external service." - Crypto / payments / finance niches, or otherwise niche problem spaces — "This is not a fit for the repo." From 67b96701c7a969963aa6d2fcaa45938060847ea9 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Tue, 29 Sep 2026 19:08:57 +1000 Subject: [PATCH 3/3] fix: keep review decisions UI-only and record external outcomes - Remove the agent-facing post_decision action to block prompt-injected approvals/rejections - Record opposite terminal labels as external GitHub decisions instead of reusing the pending command - State visibly that a reject reason is required Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../external-plugin-review-board/README.md | 2 +- .../external-plugin-review-board/extension.mjs | 16 ---------------- .../external-plugin-review-board/lib/state.mjs | 16 +++++++++++++++- .../external-plugin-review-board/public/app.js | 18 ++++++++---------- .../public/index.html | 10 ++++++++-- 5 files changed, 32 insertions(+), 30 deletions(-) diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index 50da0dbb71..5b3121faef 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -34,4 +34,4 @@ metadata, AI reviews, manual bucket overrides, and a history of decisions made f ## Agent actions -`get_board`, `refresh`, `start_review`, `start_rereview`, `record_review`, `move_item`, `get_issue`, `post_decision`. +`get_board`, `refresh`, `start_review`, `start_rereview`, `record_review`, `move_item`, `get_issue`. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 6d29ac0db1..7a839704eb 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -314,22 +314,6 @@ session = await joinSession({ issue: await getIssue(ctx.input.number), })), }, - { - name: "post_decision", - description: - "Post /approve or /reject on an issue (publicly visible; only use when the user asked). Marks it actioned.", - inputSchema: { - type: "object", - additionalProperties: false, - required: ["number", "kind"], - properties: { - number: { type: "integer" }, - kind: { type: "string", enum: ["approve", "reject"] }, - comment: { type: "string" }, - }, - }, - handler: wrap((ctx) => postDecision(ctx.input)), - }, ], open: async (ctx) => { let entry = servers.get(ctx.instanceId); diff --git a/.github/extensions/external-plugin-review-board/lib/state.mjs b/.github/extensions/external-plugin-review-board/lib/state.mjs index 091a2bebe1..f42a995214 100644 --- a/.github/extensions/external-plugin-review-board/lib/state.mjs +++ b/.github/extensions/external-plugin-review-board/lib/state.mjs @@ -247,7 +247,21 @@ export class BoardStore { const labels = new Set(status.labels ?? []); const kind = labels.has("approved") ? "approve" : labels.has("rejected") ? "reject" : null; if (!kind) return null; - const decision = { ...item.pendingDecision, kind, resolvedAt: new Date().toISOString() }; + const now = new Date().toISOString(); + const pendingKind = item.pendingDecision.kind; + const decision = + pendingKind && pendingKind !== kind + ? { + kind, + external: true, + comment: "", + body: null, + commentUrl: null, + at: now, + resolvedAt: now, + supersededPending: pendingKind, + } + : { ...item.pendingDecision, kind, resolvedAt: now }; await this.recordDecision(number, decision); return decision; } diff --git a/.github/extensions/external-plugin-review-board/public/app.js b/.github/extensions/external-plugin-review-board/public/app.js index efcbeb7364..22bcb320fd 100644 --- a/.github/extensions/external-plugin-review-board/public/app.js +++ b/.github/extensions/external-plugin-review-board/public/app.js @@ -349,16 +349,14 @@ function renderTab(item) { function renderReview(item) { const wrapper = h("div"); if (item.decision) { - wrapper.append( - h( - "p", - { class: "notice" }, - `${item.decision.kind === "approve" ? "Approved" : "Rejected"} ${relativeTime(item.decision.at)}: `, - h("code", {}, item.decision.body), - " ", - item.decision.commentUrl ? h("a", { href: item.decision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗") : null, - ), - ); + const label = item.decision.kind === "approve" ? "Approved" : "Rejected"; + const suffix = item.decision.external ? " on GitHub" : ""; + const parts = [`${label}${suffix} ${relativeTime(item.decision.at)}`]; + if (item.decision.body) parts.push(": ", h("code", {}, item.decision.body)); + if (item.decision.commentUrl) { + parts.push(" ", h("a", { href: item.decision.commentUrl, target: "_blank", rel: "noopener noreferrer" }, "comment ↗")); + } + wrapper.append(h("p", { class: "notice" }, parts)); } if (item.pendingDecision && !item.decision) { wrapper.append( diff --git a/.github/extensions/external-plugin-review-board/public/index.html b/.github/extensions/external-plugin-review-board/public/index.html index 20d40ffd05..2e9de21970 100644 --- a/.github/extensions/external-plugin-review-board/public/index.html +++ b/.github/extensions/external-plugin-review-board/public/index.html @@ -63,8 +63,14 @@