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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/extensions/duplicate-review-board/README.md
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 .github/extensions/duplicate-review-board/extension.mjs
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 }),
Comment thread
aaronpowell marked this conversation as resolved.
});
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)])),
Comment thread
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);
Loading
Loading