Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
501 changes: 501 additions & 0 deletions .github/extensions/duplicate-review-board/error-handoff.mjs

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions .github/extensions/duplicate-review-board/extension.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension";
import { CanvasError } from "@github/copilot-sdk/extension";
import { joinSession as sdkJoinSession, createCanvas as sdkCreateCanvas } from "@github/copilot-sdk/extension";
import { configureErrorHandoff } from "./error-handoff.mjs";
const { joinSession, createCanvas } = configureErrorHandoff({ joinSession: sdkJoinSession, createCanvas: sdkCreateCanvas });
import { createServer } from "./error-handoff.mjs";
import { startBoardServer, expectedError } from "../open-pr-board/lib/server.mjs";
import { BoardStore } from "./lib/state.mjs";
import { createGitHub } from "./lib/github.mjs";
Expand Down Expand Up @@ -138,12 +142,14 @@ session = await joinSession({
try { return await handler(args); }
catch (error) {
session.log(`Duplicate board ${key}: ${error.stack ?? error}`, { level: "warning", ephemeral: true });
throw expectedError(error.message, 400);
throw Object.assign(expectedError(error.message, 400),
{ handoff: error.handoff !== false });
}
}]));
// 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({
serverFactory: createServer,
publicDir,
api: {
snapshot: () => store.snapshot(), subscribe: (listener) => store.subscribe(listener), routes: wrapped,
Expand Down
31 changes: 16 additions & 15 deletions .github/extensions/duplicate-review-board/lib/publish.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { acceptGroup, digest, parseReport } from "./report.mjs";
import { expectedNotice } from "../../open-pr-board/lib/errors.mjs";

function adoptAcceptance(report, body, sourceHash) {
if (report.sourceHash !== sourceHash) return;
Expand Down Expand Up @@ -32,13 +33,13 @@ export class Publisher {
preview(number, id) {
const report = this.store.report(number);
const item = this.store.item(number, id);
if (item.removed) throw new Error("This group was removed from the report; reconcile its publication instead.");
if (item.publication || item.queueId) throw new Error("Resolve the pending review or publication first.");
if (!["separate", "consolidate"].includes(item.column)) throw new Error("Choose Keep separate or Consolidate first.");
if (item.removed) throw expectedNotice("This group was removed from the report; reconcile its publication instead.");
if (item.publication || item.queueId) throw expectedNotice("Resolve the pending review or publication first.");
if (!["separate", "consolidate"].includes(item.column)) throw expectedNotice("Choose Keep separate or Consolidate first.");
if (item.column === "consolidate" && this.store.consolidationPublication(id)) {
throw new Error("A recorded or unresolved publication exists for this group in an imported report. Reconcile instead.");
throw expectedNotice("A recorded or unresolved publication exists for this group in an imported report. Reconcile instead.");
}
if (item.accepted && item.column === "separate") throw new Error("This group is already accepted on GitHub.");
if (item.accepted && item.column === "separate") throw expectedNotice("This group is already accepted on GitHub.");
const preview = {
token: randomUUID(), number, id, revision: item.revision, sourceHash: report.sourceHash,
kind: item.column, expires: Date.now() + 5 * 60 * 1000,
Expand All @@ -54,26 +55,26 @@ export class Publisher {
async publish(token) {
const preview = this.previews.get(token);
this.previews.delete(token);
if (!preview || preview.expires < Date.now()) throw new Error("Preview expired. Preview the action again.");
if (this.busy) throw new Error("A publication is already in progress.");
if (!preview || preview.expires < Date.now()) throw expectedNotice("Preview expired. Preview the action again.");
if (this.busy) throw expectedNotice("A publication is already in progress.");
this.busy = true;
try {
const { number, id, kind } = preview;
const current = await this.github.get(number);
if (digest(current.body) !== preview.sourceHash) throw new Error("The report changed on GitHub. Refresh and preview again.");
if (kind === "separate" && current.state !== "open") throw new Error("This report is closed. Keep the decision local or use the current report.");
if (digest(current.body) !== preview.sourceHash) throw expectedNotice("The report changed on GitHub. Refresh and preview again.");
if (kind === "separate" && current.state !== "open") throw expectedNotice("This report is closed. Keep the decision local or use the current report.");
const marker = `<!-- duplicate-review-board:${id} -->`;
if (kind === "consolidate" && (await this.github.find(marker)).length) {
throw new Error("A tracking issue already exists for this group. Use Reconcile instead of creating another.");
throw expectedNotice("A tracking issue already exists for this group. Use Reconcile instead of creating another.");
}
await this.store.mutate((state) => {
const item = this.store.item(number, id, state);
const report = this.store.report(number, state);
if (kind === "consolidate" && this.store.consolidationPublication(id, state)) {
throw new Error("A recorded or unresolved publication exists for this group in an imported report. Reconcile instead.");
throw expectedNotice("A recorded or unresolved publication exists for this group in an imported report. Reconcile instead.");
}
if (item.revision !== preview.revision || report.sourceHash !== preview.sourceHash || item.publication || item.queueId || item.removed) {
throw new Error("Local triage changed. Preview the action again.");
throw expectedNotice("Local triage changed. Preview the action again.");
}
item.publication = { kind, status: "unconfirmed", at: new Date().toISOString(), marker };
item.revision = randomUUID();
Expand Down Expand Up @@ -103,7 +104,7 @@ export class Publisher {
}

async reconcile(number, id) {
if (this.busy) throw new Error("Wait for the current publication to finish.");
if (this.busy) throw expectedNotice("Wait for the current publication to finish.");
const item = this.store.item(number, id);
const intent = item.publication;
const revision = item.revision;
Expand All @@ -122,11 +123,11 @@ export class Publisher {
const matches = await this.github.find(`<!-- duplicate-review-board:${id} -->`);
if (matches.length !== 1) throw new Error(`Found ${matches.length} matching tracking issues. Inspect GitHub manually; no retry was made.`);
url = matches[0].html_url;
} else throw new Error("No publishable decision to reconcile.");
} else throw expectedNotice("No publishable decision to reconcile.");
return this.store.mutate((state) => {
const current = this.store.item(number, id, state);
if (current.revision !== revision || JSON.stringify(current.publication) !== JSON.stringify(intent)) {
throw new Error("Publication or local triage changed. Reconcile again.");
throw expectedNotice("Publication or local triage changed. Reconcile again.");
}
current.publication = { kind, status: "published", url, at: intent?.at ?? new Date().toISOString() };
current.revision = randomUUID();
Expand Down
17 changes: 9 additions & 8 deletions .github/extensions/duplicate-review-board/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { digest, parseReport } from "./report.mjs";
import { expectedNotice } from "../../open-pr-board/lib/errors.mjs";

export const COLUMNS = [
{ id: "unreviewed", label: "Unreviewed" },
Expand Down Expand Up @@ -63,14 +64,14 @@ export class BoardStore {
report(number = this.state.selected, state = this.state) {
if (!Number.isSafeInteger(number) || number < 1) throw new Error("Invalid report issue number.");
const report = state.reports[number];
if (!report) throw new Error("Import a duplicate-review report first.");
if (!report) throw expectedNotice("Import a duplicate-review report first.");
return report;
}

item(number, id, state = this.state) {
if (typeof id !== "string" || !/^[a-f0-9]{24}$/.test(id)) throw new Error("Invalid group ID.");
const item = this.report(number, state).items.find((entry) => entry.id === id);
if (!item) throw new Error("Group not found. Refresh the report.");
if (!item) throw expectedNotice("Group not found. Refresh the report.");
return item;
}

Expand Down Expand Up @@ -138,15 +139,15 @@ export class BoardStore {
return this.mutate((state) => {
const item = this.item(number, id, state);
if (typeof changes.expectedRevision !== "string" || changes.expectedRevision !== item.revision) {
throw new Error("Local triage changed. Refresh the group before saving again.");
throw expectedNotice("Local triage changed. Refresh the group before saving again.");
}
if (item.removed) throw new Error("This group was removed from the report; only publication recovery is available.");
if (item.publication) throw new Error("This group has a recorded or unresolved publication.");
if (item.removed) throw expectedNotice("This group was removed from the report; only publication recovery is available.");
if (item.publication) throw expectedNotice("This group has a recorded or unresolved publication.");
if (!COLUMNS.some((column) => column.id === changes.column)) throw new Error("Unknown triage column.");
if (typeof changes.note !== "string" || changes.note.length > 8000 ||
typeof changes.draft?.title !== "string" || !changes.draft.title.trim() || changes.draft.title.length > 256 ||
typeof changes.draft?.body !== "string" || changes.draft.body.length > 24000) {
throw new Error("Provide a note and a valid tracking-issue title/body.");
throw expectedNotice("Provide a note and a valid tracking-issue title/body.");
}
Object.assign(item, { column: changes.column, note: changes.note, draft: changes.draft, queueId: null, revision: randomUUID() });
});
Expand All @@ -155,8 +156,8 @@ export class BoardStore {
queue(number, id) {
return this.mutate((state) => {
const item = this.item(number, id, state);
if (item.removed) throw new Error("This group was removed from the report; only publication recovery is available.");
if (item.publication || item.queueId) throw new Error("This group is already queued or published.");
if (item.removed) throw expectedNotice("This group was removed from the report; only publication recovery is available.");
if (item.publication || item.queueId) throw expectedNotice("This group is already queued or published.");
item.queueId = randomUUID();
item.agentId = null;
item.reviewError = null;
Expand Down
24 changes: 15 additions & 9 deletions .github/extensions/duplicate-review-board/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function renderViewer(item) {
}).catch((error) => {
body.textContent = `Could not load: ${error.message}`;
body.classList.add("error");
if (error.handoff !== false) body.setAttribute("data-agent-error", "");
});
}
return pane;
Expand All @@ -86,19 +87,22 @@ async function api(name, body) {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`);
if (!response.ok) throw Object.assign(new Error(result.error || `Request failed (${response.status})`),
{ handoff: result.handoff !== false });
return result;
}

function message(text, error = false, target = "message") {
function message(text, error = false, target = "message", handoff = error) {
Comment thread
aaronpowell marked this conversation as resolved.
$(target).textContent = text;
$(target).classList.toggle("error", error);
if (handoff) $(target).setAttribute("data-agent-error", "");
Comment thread
aaronpowell marked this conversation as resolved.
Comment thread
aaronpowell marked this conversation as resolved.
else $(target).removeAttribute("data-agent-error");
}

async function run(button, work, target = "message") {
button.disabled = true;
try { await work(); }
catch (error) { message(error.message, true, target); }
catch (error) { message(error.message, true, target, error.handoff !== false); }
finally {
button.disabled = false;
if (opened && $("detail").open) {
Expand Down Expand Up @@ -253,6 +257,8 @@ function refreshReview(item) {
$(id).disabled = suspended;
}
if (suspended) return;
if (item.reviewError && item.reviewError !== "Review cancelled locally. Late results will be rejected.") $("review-result").setAttribute("data-agent-error", "");
else $("review-result").removeAttribute("data-agent-error");
$("review-result").textContent = item.queueId ? "Queued for a background agent. You can keep triaging." :
item.reviewError || (item.review ? `${item.review.recommendation}\n\n${item.review.rationale}` : "No comparison yet.");
$("review").disabled = Boolean(item.queueId || item.publication);
Expand Down Expand Up @@ -297,7 +303,7 @@ function editorItem() {

function requireEditorItem() {
const item = editorItem();
if (!item) throw new Error("This editor is paused. Select its source report again to continue; your edits are preserved.");
if (!item) throw Object.assign(new Error("This editor is paused. Select its source report again to continue; your edits are preserved."), { handoff: false });
return item;
}

Expand Down Expand Up @@ -413,7 +419,7 @@ async function save() {
if (current && current.revision !== edit.revision) {
opened.conflict = true;
renderConflict();
message("This group changed while saving. Resolve the conflict before saving again.", true, "detail-message");
message("This group changed while saving. Resolve the conflict before saving again.", true, "detail-message", false);
Comment thread
aaronpowell marked this conversation as resolved.
}
}
throw error;
Expand All @@ -430,7 +436,7 @@ $("discover").addEventListener("click", () => run($("discover"), async () => {
}));
$("import").addEventListener("click", () => run($("import"), async () => {
const number = Number($("reports").value);
if (!number) throw new Error("Find and select a report first.");
if (!number) throw Object.assign(new Error("Find and select a report first."), { handoff: false });
receive(await api("import", { number }));
message(`Imported report #${number}. Unchanged local decisions were preserved.`);
}));
Expand Down Expand Up @@ -496,7 +502,7 @@ $("resume-report").addEventListener("click", () => run($("resume-report"), async
}, "detail-message"));
$("review").addEventListener("click", () => run($("review"), async () => {
requireEditorItem();
if (dirty) throw new Error("Save local edits before asking for a comparison.");
if (dirty) throw Object.assign(new Error("Save local edits before asking for a comparison."), { handoff: false });
await api("review", { ...opened, guidance: $("guidance").value });
message("Comparison queued for a background agent.", false, "detail-message");
}, "detail-message"));
Expand All @@ -506,7 +512,7 @@ $("cancel-review").addEventListener("click", () => run($("cancel-review"), async
}, "detail-message"));
$("preview").addEventListener("click", () => run($("preview"), async () => {
requireEditorItem();
if (dirty) throw new Error("Save local edits before previewing.");
if (dirty) throw Object.assign(new Error("Save local edits before previewing."), { handoff: false });
preview = await api("preview", opened);
$("target").textContent = `Target: ${preview.target}`;
$("publish-title").textContent = preview.title;
Expand Down Expand Up @@ -534,6 +540,6 @@ $("reconcile").addEventListener("click", () => run($("reconcile"), async () => {

const events = new EventSource(`/events?token=${encodeURIComponent(token)}`);
events.addEventListener("board", (event) => receive(JSON.parse(event.data)));
events.addEventListener("error", () => message("Connection interrupted. Local state is saved; reconnecting...", true));
events.addEventListener("error", () => message("Connection interrupted. Local state is saved; reconnecting...", true, "message", false));
events.addEventListener("open", () => message("Connected. Triage is local until you confirm a GitHub action."));
api("board").then(receive).catch((error) => message(error.message, true));
6 changes: 6 additions & 0 deletions .github/extensions/external-plugin-review-board/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# External Plugin Review Board

Handled errors also offer an optional **Send to agent** action. It requests a child
session to investigate the error; retry and recovery controls stay unchanged.
Creation is only confirmed after the parent agent reports the actual child ID.
See [canvas error handoff](../../../docs/maintainers/canvas-evidence-and-metrics.md#optional-error-handoff)
for privacy, pending-state and failure behavior.

A maintainer-only Copilot canvas extension for triaging active `external-plugin` submissions in
github/awesome-copilot.

Expand Down
Loading
Loading