Repository navigation
Add a local duplicate-resource review canvas #5253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ab7f05f
feat(canvas): add local duplicate review board
aaronpowell 64ba48a
fix(canvas): harden duplicate review state and publication
aaronpowell 5677372
fix(canvas): preserve duplicate review freshness and editor scope
aaronpowell 14821c6
fix(canvas): verify provenance and constrain review mutations
aaronpowell 1d313b0
fix(canvas): minimize agent-visible review data
aaronpowell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| # Duplicate Review Board | ||
|
|
||
| A repository-local maintainer canvas for turning the weekly | ||
| [Duplicate Resource Detector](../../workflows/duplicate-resource-detector.md) | ||
| reports into actionable, local triage. It is not an installable marketplace plugin. | ||
|
|
||
| ## Workflow | ||
|
|
||
| 1. Open canvas `duplicate-review-board`. **Find reports** lists the 100 most recent | ||
| open and closed `duplicate-review` issues in `github/awesome-copilot`. | ||
| Select a report and **Import / refresh from GitHub**. Opening the canvas alone | ||
| does not fetch reports or change GitHub. | ||
| 2. Search groups and resources; drag cards or use their accessible decision | ||
| selector to choose **Unreviewed**, **Keep separate**, **Consolidate**, or | ||
| **Needs insight**. Open a card to read each resource's contents from `main` | ||
| directly in the canvas (tabbed or side by side), plus detector evidence, private | ||
| notes, and an editable consolidation-issue draft. Save edits locally. | ||
| 3. **Compare in background** starts a native background agent using | ||
| `session.rpc.tasks.startAgent`, never `session.send` or a foreground review. | ||
| It compares source files read-only and returns a recommendation with evidence. | ||
| Optional guidance focuses a re-review. AI results never change your decision | ||
| or issue draft. Runtimes without background-task support report an error; | ||
| there is no foreground fallback. | ||
| Local notes are not included in the background prompt. Report and resource | ||
| text remain untrusted evidence; prompt boundaries guide the comparison but | ||
| are not a guarantee against prompt injection. | ||
| 4. **Preview GitHub action** shows the exact destination and complete payload. | ||
| A separate **Confirm and publish** click is required: | ||
| - **Keep separate** checks only this group's `Reviewed` checkbox in an open | ||
| report, preserving the rest of the Markdown byte-for-byte. The detector can | ||
| subsequently use these acceptances to exclude known intentional overlap. | ||
| - **Consolidate** creates a tracking issue containing the saved draft and a | ||
| stable group marker. It never edits, deletes, or merges resource files. | ||
| Private notes are not posted unless you put them into the issue draft. | ||
|
|
||
| Background workers with canvas tools record their own results. If a worker does | ||
| not have those tools, it returns the exact result JSON for the parent agent to | ||
| record through `record_review` or `fail_review` on completion. It should not run | ||
| another comparison in the parent. The stored agent ID identifies the task. | ||
| **Cancel queued review** invalidates its local token; it does not stop an already | ||
| running read-only worker. Late results are rejected. | ||
|
|
||
| ## State and refresh | ||
|
|
||
| State is atomically saved under | ||
| `<session.workspacePath>/extensions/duplicate-review-board/state/board.json`, | ||
| outside the checkout and extension directory. Multiple panels share the same | ||
| session's reports and live updates; reloading the extension or resuming the session | ||
| preserves decisions. A new conversation has separate state. | ||
| Persisted snapshot sequence numbers keep delayed HTTP responses from replacing | ||
| newer live updates. | ||
|
|
||
| Reports are keyed by issue number; groups by a hash of their sorted resource | ||
| paths, not display numbers or panel IDs. Renumbering a group preserves its local | ||
| state. Changed titles, descriptions, or similarity evidence invalidate reviews | ||
| and decisions while preserving notes, saved consolidation drafts, and publication receipts. Removed groups | ||
| with publication records remain accessible for recovery; other removed groups | ||
| disappear when the report is imported again. Other imported reports | ||
| remain available. A report format the parser cannot safely understand is rejected | ||
| explicitly, without changing the saved state. | ||
|
|
||
| Saving or moving a queued group cancels its local token. Failed background | ||
| dispatch clears the queue and records the error. Failed or cancelled comparisons | ||
| can be requested again. | ||
| Dropping a group into its existing column does not change its queue or revision. | ||
|
|
||
| Edits carry the group's loaded revision. A concurrent edit or changed report | ||
| rejects a stale save instead of overwriting newer triage. | ||
| If another panel selects a different report, the open editor pauses without | ||
| discarding drafts. Resume its source report from the dialog to continue editing. | ||
|
|
||
| Resource previews share only in-flight requests in the panel; completed reads | ||
| use the server's ten-minute cache rather than an indefinite browser cache. | ||
| Display-only controls do not count as unsaved triage. | ||
|
|
||
| ## Remote safeguards and recovery | ||
|
|
||
| The authenticated `gh` CLI is required for GitHub reads and confirmed writes. | ||
| The board is intentionally restricted to this repository. GitHub permissions | ||
| still apply. No write is exposed as an agent-callable canvas action. | ||
|
|
||
| The loopback token protects browser requests, not against other processes running | ||
| as the same OS user. Such processes can fetch the page/token or invoke `gh` | ||
| directly. The confirmation dialog and single-use preview bind the UI publication | ||
| flow to its displayed payload; they are not proof of human identity. A caller-set | ||
| `confirmed` flag would not provide that proof either. Run only trusted local | ||
| agents and tools with these credentials; isolating an untrusted worker requires | ||
| runtime-enforced filesystem, network, and credential restrictions outside this | ||
| extension. | ||
|
|
||
| Previews expire after five minutes, are single-use, and are invalidated by local | ||
| edits or a changed source report. The report is re-read immediately before a write. | ||
| GitHub issue-body updates do not provide transactional compare-and-swap; avoid | ||
| simultaneous manual editing while publishing an acceptance. | ||
|
|
||
| A publication intent is persisted **before** the remote write. Published and | ||
| unconfirmed groups are locked against editing and repeat publication. Consolidation | ||
| records lock the same resource group across every imported report, including | ||
| removed recovery entries. Network | ||
| failures or a lost response never trigger an automatic retry. **Reconcile with | ||
| GitHub** verifies the acceptance checkbox or searches all repository issues for | ||
| the stable consolidation marker, then saves a local receipt without writing to | ||
| GitHub. A marker match counts only when GitHub verifies that its author has | ||
| write, maintain, or admin permission on this repository. Copied markers from | ||
| untrusted authors cannot block a new consolidation or become its receipt. | ||
| The verified marker prevents a later weekly report creating another tracking | ||
| issue for the same paths. If no unique trusted result is found, the error remains visible: | ||
| inspect GitHub and the saved state manually rather than retrying blindly. | ||
|
|
||
| Reconciling an acceptance advances the saved report body and hash only when all | ||
| active groups still match their imported evidence. Otherwise, import the report | ||
| again before publishing another group's decision. | ||
|
|
||
| ## Agent actions | ||
|
|
||
| | Action | Purpose | | ||
| | --- | --- | | ||
| | `get_board` | Read an agent-safe view of report evidence, queue tokens, and receipts. | | ||
| | `list_reports` | Discover report issues, read-only. | | ||
| | `import_report` | Import or refresh a report from GitHub. | | ||
| | `select_report` | Switch between imported local reports. | | ||
| | `read_resource` | Read a listed resource from `main` inside the canvas. | | ||
| | `start_review` | Dispatch one native background comparison. | | ||
| | `record_review` | Record a result matching the current queue token. | | ||
| | `fail_review` | Record a failure matching the current queue token. | | ||
|
|
||
| Decision/draft updates and review cancellation are available only through the | ||
| authenticated loopback UI, not agent-callable canvas actions. The SDK does not | ||
| provide a trusted caller-identity flag for distinguishing a maintainer from a | ||
| background agent. UI updates still require `expectedRevision`. | ||
| Agent-callable snapshot results omit local notes and consolidation drafts for | ||
| every caller; the loopback UI retains the full editor data. | ||
|
|
||
| The loopback UI reuses the Open PR Board's authenticated HTTP/SSE server, has | ||
| searchable kanban cards, native modal focus management, keyboard-operable decision | ||
| selectors, app theme tokens, and explicit errors. All report and AI content is | ||
| rendered as text, not executable HTML. | ||
|
|
||
| ## Regression tests | ||
|
|
||
| From the repository root: | ||
|
|
||
| ```sh | ||
| node --experimental-vm-modules --test eng/duplicate-review-board*.test.mjs | ||
| ``` | ||
|
|
||
| Tests use fake GitHub clients and SDK modules; they never write to GitHub or start | ||
| real background reviews. |
167 changes: 167 additions & 0 deletions
167
.github/extensions/duplicate-review-board/extension.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import path from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; | ||
| import { startBoardServer, expectedError } from "../open-pr-board/lib/server.mjs"; | ||
| import { BoardStore } from "./lib/state.mjs"; | ||
| import { createGitHub } from "./lib/github.mjs"; | ||
| import { Publisher } from "./lib/publish.mjs"; | ||
| import { buildReviewPrompt } from "./lib/review.mjs"; | ||
|
|
||
| const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "public"); | ||
| const panels = new Map(); | ||
| const github = createGitHub(); | ||
| let store; | ||
| let publisher; | ||
| let session; | ||
|
|
||
| const object = (properties = {}, required = []) => ({ type: "object", additionalProperties: false, properties, required }); | ||
| const identity = { number: { type: "integer", minimum: 1 }, id: { type: "string", pattern: "^[a-f0-9]{24}$" } }; | ||
| const reviewIdentity = { ...identity, queueId: { type: "string", minLength: 1 } }; | ||
| const pick = (value, fields) => Object.fromEntries(fields.filter((field) => Object.hasOwn(value, field)).map((field) => [field, value[field]])); | ||
| function agentSnapshot(snapshot) { | ||
| return { | ||
| ...pick(snapshot, ["repo", "selected", "snapshotSequence"]), | ||
| columns: snapshot.columns.map((column) => pick(column, ["id", "label"])), | ||
| reports: snapshot.reports.map((report) => pick(report, ["number", "title", "url", "state", "importedAt"])), | ||
| items: snapshot.items.map((item) => ({ | ||
| ...pick(item, [ | ||
| "id", "number", "title", "reason", "accepted", "fingerprint", "removed", "column", | ||
| "queueId", "agentId", "reviewError", "changed", "revision", | ||
| ]), | ||
| resources: item.resources.map((resource) => pick(resource, ["path", "type", "description"])), | ||
| review: item.review && pick(item.review, ["recommendation", "rationale", "at"]), | ||
| publication: item.publication && pick(item.publication, ["kind", "status", "at", "url"]), | ||
| })), | ||
| }; | ||
| } | ||
|
|
||
| async function startReview(number, id, instanceId, guidance = "") { | ||
| if (typeof guidance !== "string" || guidance.length > 4000) throw new Error("Guidance must be at most 4000 characters."); | ||
| const item = await store.queue(number, id); | ||
| try { | ||
| if (!session.rpc.tasks?.startAgent) throw new Error("This runtime does not support background agent tasks. Update Copilot; foreground fallback is disabled."); | ||
| const { agentId } = await session.rpc.tasks.startAgent({ | ||
| agentType: "general-purpose", | ||
| name: `duplicate-group-${item.number}`, | ||
| description: `Compare duplicate report #${number}, group ${item.number}`, | ||
| prompt: buildReviewPrompt({ number, item, instanceId, guidance }), | ||
| }); | ||
| await store.attachAgent(number, id, item.queueId, agentId); | ||
| return { queued: item.id, queueId: item.queueId, agentId }; | ||
| } catch (error) { | ||
| if (store.item(number, id).queueId === item.queueId) { | ||
| await store.finish(number, id, item.queueId, { error: `Could not dispatch background review: ${error.message}` }); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| const resourceCache = new Map(); | ||
| const MAX_RESOURCE_CHARS = 200_000; | ||
|
|
||
| async function readResource({ number, id, path: file }) { | ||
| const item = store.item(number, id); | ||
| if (!item.resources.some((resource) => resource.path === file)) throw new Error("That file is not part of this duplicate group."); | ||
| const cached = resourceCache.get(file); | ||
| if (cached && Date.now() - cached.at < 10 * 60_000) return cached.value; | ||
| const content = await github.content(file); | ||
| const value = { | ||
| path: file, ref: "main", | ||
| content: content.slice(0, MAX_RESOURCE_CHARS), | ||
| truncated: content.length > MAX_RESOURCE_CHARS, | ||
| }; | ||
| resourceCache.set(file, { at: Date.now(), value }); | ||
| return value; | ||
| } | ||
|
|
||
| const operations = { | ||
| reports: () => github.list(), | ||
| resource: readResource, | ||
| import: async ({ number }) => store.import(await github.get(number)), | ||
| select: ({ number }) => store.select(number), | ||
| update: ({ number, id, ...changes }) => store.update(number, id, changes), | ||
| cancel: ({ number, id }) => store.cancel(number, id), | ||
| preview: ({ number, id }) => publisher.preview(number, id), | ||
| publish: ({ token }) => publisher.publish(token), | ||
| reconcile: ({ number, id }) => publisher.reconcile(number, id), | ||
| }; | ||
|
|
||
| function action(name, description, inputSchema, handler) { | ||
| return { | ||
| name, description, inputSchema, | ||
| handler: async (ctx) => { | ||
| try { | ||
| if (!store) throw new Error("The duplicate review board is still starting."); | ||
| return await handler(ctx.input ?? {}, ctx); | ||
| } catch (error) { | ||
| throw new CanvasError("duplicate_board_error", error.message); | ||
| } | ||
| }, | ||
|
Comment on lines
+74
to
+81
|
||
| }; | ||
| } | ||
|
|
||
| session = await joinSession({ | ||
| canvases: [createCanvas({ | ||
| id: "duplicate-review-board", | ||
| displayName: "Duplicate Review Board", | ||
| description: "Triage duplicate-resource reports locally, compare groups in background agents, and preview confirmed acceptance or consolidation actions.", | ||
| inputSchema: object(), | ||
| actions: [ | ||
| action("get_board", "Read local reports, groups, decisions, AI reviews, and publication receipts; private notes and issue drafts are omitted.", object(), () => agentSnapshot(store.snapshot())), | ||
| action("list_reports", "List duplicate-review report issues without importing or changing them.", object(), operations.reports), | ||
| action("import_report", "Read a report from GitHub; preserve unchanged local decisions and flag changed groups. Private notes and issue drafts are omitted.", object({ number: identity.number }, ["number"]), async (input) => agentSnapshot(await operations.import(input))), | ||
| action("read_resource", "Read a resource file in a group from the main branch, without changing GitHub or local state.", object({ | ||
| ...identity, path: { type: "string", minLength: 1, maxLength: 300 }, | ||
| }, ["number", "id", "path"]), operations.resource), | ||
| action("select_report", "Switch to a previously imported local report, without accessing GitHub. Private notes and issue drafts are omitted.", object({ number: identity.number }, ["number"]), async (input) => agentSnapshot(await operations.select(input))), | ||
| action("start_review", "Dispatch a read-only comparison through a background agent only; never review in the foreground.", object({ | ||
|
|
||
| ...identity, guidance: { type: "string", maxLength: 4000 }, | ||
| }, ["number", "id"]), (input, ctx) => startReview(input.number, input.id, ctx.instanceId, input.guidance)), | ||
| action("record_review", "Record the exact queued background result without changing the maintainer's decision.", object({ | ||
| ...reviewIdentity, recommendation: { type: "string", enum: ["separate", "consolidate", "deferred"] }, | ||
| rationale: { type: "string", minLength: 1, maxLength: 16000 }, | ||
| }, ["number", "id", "queueId", "recommendation", "rationale"]), async (input) => agentSnapshot(await store.finish(input.number, input.id, input.queueId, input))), | ||
| action("fail_review", "Record an explicit background-review failure for the matching queue token.", object({ | ||
| ...reviewIdentity, error: { type: "string", minLength: 1, maxLength: 4000 }, | ||
| }, ["number", "id", "queueId", "error"]), async (input) => agentSnapshot(await store.finish(input.number, input.id, input.queueId, input))), | ||
| ], | ||
| open: async (ctx) => { | ||
| if (!store) throw new CanvasError("not_ready", "The board is still starting. Retry opening it."); | ||
| let panel = panels.get(ctx.instanceId); | ||
| if (!panel) { | ||
| const routes = { | ||
| "GET /api/board": () => store.snapshot(), | ||
| ...Object.fromEntries(Object.entries(operations).map(([name, handler]) => [`POST /api/${name}`, ({ body }) => handler(body)])), | ||
|
aaronpowell marked this conversation as resolved.
|
||
| "POST /api/review": ({ body }) => startReview(body.number, body.id, ctx.instanceId, body.guidance), | ||
| }; | ||
| const wrapped = Object.fromEntries(Object.entries(routes).map(([key, handler]) => [key, async (args) => { | ||
| try { return await handler(args); } | ||
| catch (error) { | ||
| session.log(`Duplicate board ${key}: ${error.stack ?? error}`, { level: "warning", ephemeral: true }); | ||
| throw expectedError(error.message, 400); | ||
| } | ||
| }])); | ||
| // This loopback token is not a trusted caller identity: same-user local agents can fetch the page and token. | ||
| // Keep human confirmation as UI workflow, not authorization; the SDK exposes no authenticated caller context. | ||
| panel = await startBoardServer({ | ||
| publicDir, | ||
| api: { | ||
| snapshot: () => store.snapshot(), subscribe: (listener) => store.subscribe(listener), routes: wrapped, | ||
| logger: { error: (message, error) => session.log(`${message}: ${error}`, { level: "error" }) }, | ||
| }, | ||
| }); | ||
| panels.set(ctx.instanceId, panel); | ||
| } | ||
| return { title: "Duplicate review board", url: panel.url }; | ||
| }, | ||
| onClose: async (ctx) => { | ||
| const panel = panels.get(ctx.instanceId); | ||
| panels.delete(ctx.instanceId); | ||
| await panel?.close(); | ||
| }, | ||
| })], | ||
| }); | ||
|
|
||
| if (!session.workspacePath) throw new Error("Duplicate Review Board requires a session workspace for persistent local state."); | ||
| store = await new BoardStore(path.join(session.workspacePath, "extensions", "duplicate-review-board", "state", "board.json")).load(); | ||
| publisher = new Publisher(store, github); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.