mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
fix(canvas): persist board state in the session workspace
Keep runtime writes outside extension folders to avoid invalidating their safety fingerprints. Import legacy board state without modifying it and document session isolation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 545df520-6557-4211-a110-53c60f4b9f3a
This commit is contained in:
co-authored by
Copilot App
parent
8bc0f5715b
commit
f4319559ab
@@ -24,8 +24,16 @@ 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.
|
||||
|
||||
If no session state exists, the board imports the legacy `state/board.json` next to this file when
|
||||
present, without modifying it. Existing session state always takes precedence.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -35,3 +43,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 and legacy imports 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;
|
||||
@@ -330,6 +330,7 @@ session = await joinSession({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
servers.set(ctx.instanceId, entry);
|
||||
}
|
||||
if (!store.state.lastRefreshedAt) {
|
||||
@@ -350,3 +351,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({ legacyFile: path.join(EXT_DIR, "state", "board.json") });
|
||||
|
||||
@@ -41,14 +41,25 @@ export class BoardStore {
|
||||
this.writeChain = Promise.resolve();
|
||||
}
|
||||
|
||||
async load() {
|
||||
async load({ legacyFile } = {}) {
|
||||
let raw;
|
||||
let imported = false;
|
||||
try {
|
||||
const raw = await readFile(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo };
|
||||
raw = await readFile(this.filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
if (!legacyFile) return this;
|
||||
try {
|
||||
raw = await readFile(legacyFile, "utf8");
|
||||
imported = true;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo };
|
||||
if (imported) await this.commit();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,18 @@ 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.
|
||||
|
||||
If no session state exists, the board imports the legacy
|
||||
`.github/extensions/open-pr-board/state/<owner>__<repo>.json` file when present,
|
||||
without modifying it. Existing session state always takes precedence.
|
||||
Use **Reset board** (or the `reset_board` action) to clear reviews and history without
|
||||
touching GitHub.
|
||||
|
||||
## Agent actions
|
||||
|
||||
@@ -78,3 +87,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 and legacy imports 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,8 +41,10 @@ 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`);
|
||||
store = await new BoardStore({ file, repo }).load();
|
||||
const filename = `${repo.replace("/", "__")}.json`;
|
||||
const file = path.join(session.workspacePath, "extensions", CANVAS_ID, "state", filename);
|
||||
const legacyFile = path.join(EXT_DIR, "state", filename);
|
||||
store = await new BoardStore({ file, repo }).load({ legacyFile });
|
||||
stores.set(repo, store);
|
||||
}
|
||||
return store;
|
||||
@@ -399,6 +401,7 @@ session = await joinSession({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
entry = { repo, ...server };
|
||||
servers.set(ctx.instanceId, entry);
|
||||
}
|
||||
@@ -421,3 +424,7 @@ session = await joinSession({
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!session.workspacePath) {
|
||||
throw new Error("Open PR Board requires a session workspace for persistent state. Enable infinite sessions.");
|
||||
}
|
||||
|
||||
@@ -68,19 +68,30 @@ export class BoardStore {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async load() {
|
||||
async load({ legacyFile } = {}) {
|
||||
let raw;
|
||||
let imported = false;
|
||||
try {
|
||||
const raw = await readFile(this.file, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
this.state = {
|
||||
repo: this.repo,
|
||||
updatedAt: parsed.updatedAt ?? null,
|
||||
items: parsed.items && typeof parsed.items === "object" ? parsed.items : {},
|
||||
history: Array.isArray(parsed.history) ? parsed.history : [],
|
||||
};
|
||||
raw = await readFile(this.file, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
if (!legacyFile) return this;
|
||||
try {
|
||||
raw = await readFile(legacyFile, "utf8");
|
||||
imported = true;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
this.state = {
|
||||
repo: this.repo,
|
||||
updatedAt: parsed.updatedAt ?? null,
|
||||
items: parsed.items && typeof parsed.items === "object" ? parsed.items : {},
|
||||
history: Array.isArray(parsed.history) ? parsed.history : [],
|
||||
};
|
||||
if (imported) await this.persist();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user