chore: publish from main

This commit is contained in:
github-actions[bot]
2026-10-12 02:59:31 +00:00
parent d24b7dba5e
commit 8d37eaee3e
5 changed files with 241 additions and 10 deletions
@@ -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
`<session.workspacePath>/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.
@@ -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();
+11 -3
View File
@@ -46,9 +46,15 @@ branch-protection rules still apply.
## State
Board state lives in `.github/extensions/open-pr-board/state/<owner>__<repo>.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
`<session.workspacePath>/extensions/open-pr-board/state/<owner>__<repo>.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.
@@ -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/<owner>__<repo>.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.");
}
+195
View File
@@ -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");
});