diff --git a/.github/extensions/external-plugin-review-board/README.md b/.github/extensions/external-plugin-review-board/README.md index 5b3121fae..478324ec8 100644 --- a/.github/extensions/external-plugin-review-board/README.md +++ b/.github/extensions/external-plugin-review-board/README.md @@ -24,8 +24,14 @@ Open it by asking Copilot to "open the plugin review board" (canvas id `external ## State -Board state lives in `state/board.json` next to this file and is gitignored. It stores the synced issue +Board state lives in +`/extensions/external-plugin-review-board/state/board.json`. +This is Copilot's session-state workspace, not the repository checkout. It stores the synced issue metadata, AI reviews, manual bucket overrides, and a history of decisions made from the board. +State survives extension reloads and resuming the same session; a new session has its own board. +Infinite sessions must be enabled to provide the workspace. Keeping writes outside the extension +folder avoids invalidating its safety fingerprint. State previously stored in the extension folder is +not imported; boards start fresh. ## Requirements @@ -35,3 +41,8 @@ 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`. + +## Regression tests + +Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` from the repository root +to check both canvases' session-scoped persistence without accessing GitHub. diff --git a/.github/extensions/external-plugin-review-board/extension.mjs b/.github/extensions/external-plugin-review-board/extension.mjs index 7a839704e..c65797e17 100644 --- a/.github/extensions/external-plugin-review-board/extension.mjs +++ b/.github/extensions/external-plugin-review-board/extension.mjs @@ -1,7 +1,7 @@ // 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. +// quick approve/reject, and an issue detail view. Board state is persisted under +// session.workspacePath so writes do not change the extension fingerprint. import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,7 +18,7 @@ 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(); +let store; const servers = new Map(); const detailCache = new Map(); let refreshInFlight = null; @@ -213,6 +213,7 @@ function routesFor(instanceId) { function wrap(fn) { return async (ctx) => { try { + if (!store) throw new CanvasError("not_ready", "The extension is still starting. Retry the action."); return await fn(ctx); } catch (error) { if (error instanceof CanvasError) throw error; @@ -316,6 +317,7 @@ session = await joinSession({ }, ], open: async (ctx) => { + if (!store) throw new CanvasError("not_ready", "The extension is still starting. Retry opening it."); let entry = servers.get(ctx.instanceId); if (!entry) { entry = await startBoardServer({ @@ -330,6 +332,7 @@ session = await joinSession({ }, }, }); + servers.set(ctx.instanceId, entry); } if (!store.state.lastRefreshedAt) { @@ -350,3 +353,11 @@ session = await joinSession({ }), ], }); + +if (!session.workspacePath) { + throw new Error("External Plugin Review Board requires a session workspace for persistent state. Enable infinite sessions."); +} +store = await new BoardStore( + path.join(session.workspacePath, "extensions", CANVAS_ID, "state", "board.json"), + REPO, +).load(); diff --git a/.github/extensions/open-pr-board/README.md b/.github/extensions/open-pr-board/README.md index ebe6e972a..225d2874e 100644 --- a/.github/extensions/open-pr-board/README.md +++ b/.github/extensions/open-pr-board/README.md @@ -46,9 +46,15 @@ branch-protection rules still apply. ## State -Board state lives in `.github/extensions/open-pr-board/state/__.json`, one -file per repository, and is git-ignored. Use **Reset board** (or the `reset_board` -action) to clear reviews and history without touching GitHub. +Board state lives in +`/extensions/open-pr-board/state/__.json`, one +file per repository. This is Copilot's session-state workspace, not the repository +checkout. State survives extension reloads and resuming the same session; a new +session has its own board. Infinite sessions must be enabled to provide the workspace. +Keeping writes outside the extension folder avoids invalidating its safety fingerprint. +State previously stored in the extension folder is not imported; boards start fresh. +Use **Reset board** (or the `reset_board` action) to clear reviews and history without +touching GitHub. ## Agent actions @@ -78,3 +84,5 @@ Results arriving after a manual move, reset, or replacement review are rejected. Run `node --test eng/open-pr-board.test.mjs` from the repository root. GitHub writes are tested with injected in-process fakes, never the authenticated CLI. +Run `node --experimental-vm-modules --test eng/board-state-storage.test.mjs` to check +both canvases' session-scoped persistence without accessing GitHub. diff --git a/.github/extensions/open-pr-board/extension.mjs b/.github/extensions/open-pr-board/extension.mjs index cce764687..b51eb98e5 100644 --- a/.github/extensions/open-pr-board/extension.mjs +++ b/.github/extensions/open-pr-board/extension.mjs @@ -2,8 +2,8 @@ // Review board for open pull requests: AI review with persistent state tracking, // approve / request-changes / needs-insight / close recommendations, visible labels, // and GitHub actions. Decisions are applied through the authenticated gh CLI and -// recorded on the board. Board state is persisted to -// ./state/__.json (gitignored) so it survives reloads and new sessions. +// recorded on the board. Board state is persisted under session.workspacePath +// so writes do not change the extension fingerprint. import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,7 +41,8 @@ async function getStore(repo) { let store = stores.get(repo); if (!store) { // The repo is validated against REPO_PATTERN above, so it cannot escape the state directory. - const file = path.join(EXT_DIR, "state", `${repo.replace("/", "__")}.json`); + const filename = `${repo.replace("/", "__")}.json`; + const file = path.join(session.workspacePath, "extensions", CANVAS_ID, "state", filename); store = await new BoardStore({ file, repo }).load(); stores.set(repo, store); } @@ -399,6 +400,7 @@ session = await joinSession({ }, }, }); + entry = { repo, ...server }; servers.set(ctx.instanceId, entry); } @@ -421,3 +423,7 @@ session = await joinSession({ }), ], }); + +if (!session.workspacePath) { + throw new Error("Open PR Board requires a session workspace for persistent state. Enable infinite sessions."); +} diff --git a/eng/board-state-storage.test.mjs b/eng/board-state-storage.test.mjs new file mode 100644 index 000000000..3db0f568b --- /dev/null +++ b/eng/board-state-storage.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import vm from "node:vm"; +import { test } from "node:test"; + +const extensionsDir = fileURLToPath(new URL("../.github/extensions/", import.meta.url)); +const repo = "github/awesome-copilot"; +const item = { + number: 1, title: "Example", author: "contributor", labels: [], + url: "https://github.com/github/awesome-copilot/issues/1", +}; + +async function fixture(t, id) { + const root = await mkdtemp(path.join(os.tmpdir(), "board-state-storage-")); + t.after(() => rm(root, { recursive: true, force: true })); + const extensionDir = path.join(root, id); + await cp(path.join(extensionsDir, id), extensionDir, { recursive: true }); + const workspacePath = path.join(root, "session"); + const filename = id === "open-pr-board" ? "github__awesome-copilot.json" : "board.json"; + return { + id, root, extensionDir, workspacePath, + legacyFile: path.join(extensionDir, "state", filename), + stateFile: path.join(workspacePath, "extensions", id, "state", filename), + }; +} + +async function fingerprint(dir) { + const files = []; + for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(dir, entry.name); + files.push([entry.name, entry.isDirectory() + ? await fingerprint(file) + : createHash("sha256").update(await readFile(file)).digest("hex")]); + } + return files; +} + +async function loadCanvas({ id, extensionDir, workspacePath, triggerDuringJoin = false }) { + let canvas; + let startupResults; + const context = vm.createContext({ setTimeout }); + const sdk = { + createCanvas: (definition) => definition, + joinSession: async (options) => { + [canvas] = options.canvases; + if (triggerDuringJoin) { + const ctx = { instanceId: "startup-test", input: {} }; + const requests = [ + canvas.actions.find((entry) => entry.name === "get_board").handler(ctx), + canvas.open(ctx), + ]; + startupResults = Promise.all(requests.map(async (request) => { + try { + await request; + return { ok: true }; + } catch (error) { + return { ok: false, code: error.code, message: error.message }; + } + })); + } + return { workspacePath, log() {}, send: async () => {} }; + }, + CanvasError: class CanvasError extends Error { + constructor(code, message) { super(message); this.code = code; } + }, + }; + const filename = path.join(extensionDir, "extension.mjs"); + const module = new vm.SourceTextModule(await readFile(filename, "utf8"), { + context, + initializeImportMeta(meta) { meta.url = pathToFileURL(filename).href; }, + }); + await module.link(async (specifier) => { + let exports; + if (specifier === "@github/copilot-sdk/extension") { + exports = sdk; + } else if (specifier === "./lib/github.mjs") { + exports = { + listOpenPullRequests: async () => [item], + listReadyIssues: async () => [item], + }; + } else { + exports = await import(specifier.startsWith(".") + ? pathToFileURL(path.join(extensionsDir, id, specifier)).href + : specifier); + } + return new vm.SyntheticModule(Object.keys(exports), function () { + for (const [name, value] of Object.entries(exports)) this.setExport(name, value); + }, { context }); + }); + await module.evaluate(); + const act = (name, input = {}) => { + const action = canvas.actions.find((entry) => entry.name === name); + assert.ok(action, `Missing canvas action ${name}`); + return action.handler({ instanceId: "test-board", input }); + }; + act.startupResults = startupResults; + return act; +} + +async function writeJSON(file, value) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value), "utf8"); +} + +for (const id of ["open-pr-board", "external-plugin-review-board"]) { + const column = id === "open-pr-board" ? "needs-insight" : "needs-review"; + + test(`${id} writes only in its session namespace and survives reloads`, async (t) => { + const f = await fixture(t, id); + const before = await fingerprint(f.extensionDir); + const act = await loadCanvas(f); + await act("refresh"); + await act("move_item", { number: 1, column }); + assert.equal(JSON.parse(await readFile(f.stateFile, "utf8")).items["1"].manualColumn, column); + assert.deepEqual(await fingerprint(f.extensionDir), before); + + const reloaded = await loadCanvas(f); + assert.equal((await reloaded("get_board")).items[0].manualOverride, column); + const otherSession = await loadCanvas({ ...f, workspacePath: path.join(f.root, "other-session") }); + assert.equal((await otherSession("get_board")).items.length, 0); + + if (id === "open-pr-board") { + await act("refresh", { repo: "example/other" }); + const otherFile = path.join(path.dirname(f.stateFile), "example__other.json"); + assert.equal(JSON.parse(await readFile(otherFile, "utf8")).repo, "example/other"); + await act("reset_board"); + const afterReset = await loadCanvas(f); + assert.equal((await afterReset("get_board")).items[0].manualOverride, null); + } + assert.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} starts fresh and ignores state stored in the extension folder`, async (t) => { + const f = await fixture(t, id); + await writeJSON(f.legacyFile, { + version: 1, repo, items: { "1": { ...item, manualColumn: column, reviewStatus: "reviewed" } }, + history: [{ number: 1, comment: "Old history" }], + }); + const before = await fingerprint(f.extensionDir); + const act = await loadCanvas(f); + assert.equal((await act("get_board")).items.length, 0); + await act("refresh"); + assert.equal((await act("get_board")).items[0].manualOverride, null); + assert.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} rejects a missing session workspace instead of writing to the extension`, async (t) => { + const f = await fixture(t, id); + const before = await fingerprint(f.extensionDir); + await assert.rejects(loadCanvas({ ...f, workspacePath: undefined }), /requires a session workspace/); + assert.deepEqual(await fingerprint(f.extensionDir), before); + }); + + test(`${id} surfaces corrupt session state`, async (t) => { + const f = await fixture(t, id); + await mkdir(path.dirname(f.stateFile), { recursive: true }); + await writeFile(f.stateFile, "not json", "utf8"); + await assert.rejects(async () => { + const act = await loadCanvas(f); + await act("get_board"); + }, /JSON|Unexpected token/); + }); +} + +test("external-plugin-review-board returns retryable not_ready errors during startup", async (t) => { + const f = await fixture(t, "external-plugin-review-board"); + const act = await loadCanvas({ ...f, triggerDuringJoin: true }); + const results = await act.startupResults; + assert.equal(results.length, 2); + for (const result of results) { + assert.equal(result.ok, false); + assert.equal(result.code, "not_ready"); + assert.match(result.message, /still starting/i); + } + assert.equal((await act("get_board")).items.length, 0); +}); + +test("both canvases keep independent boards in the same session workspace", async (t) => { + const pr = await fixture(t, "open-pr-board"); + const plugin = { ...await fixture(t, "external-plugin-review-board"), workspacePath: pr.workspacePath }; + const prBoard = await loadCanvas(pr); + const pluginBoard = await loadCanvas(plugin); + await prBoard("refresh"); + await pluginBoard("refresh"); + await prBoard("move_item", { number: 1, column: "needs-insight" }); + await pluginBoard("move_item", { number: 1, column: "accept" }); + const reloadedPR = await loadCanvas(pr); + const reloadedPlugin = await loadCanvas(plugin); + assert.equal((await reloadedPR("get_board")).items[0].manualOverride, "needs-insight"); + assert.equal((await reloadedPlugin("get_board")).items[0].manualOverride, "accept"); +});