Files
awesome-copilot/.github/extensions/open-pr-board/lib/state.mjs
T
Aaron PowellandCopilot App f4319559ab 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
2026-10-12 12:19:43 +11:00

307 lines
11 KiB
JavaScript

import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import path from "node:path";
export const RECOMMENDATIONS = ["close", "request-changes", "needs-insight", "approve"];
export const COLUMNS = [
{ id: "unreviewed", label: "Unreviewed", droppable: true },
{ id: "reviewing", label: "Reviewing", droppable: false },
{ id: "close", label: "Close", droppable: true },
{ id: "request-changes", label: "Request changes", droppable: true },
{ id: "needs-insight", label: "Needs insight", droppable: true },
{ id: "approve", label: "Approve", droppable: true },
{ id: "actioned", label: "Actioned", droppable: false },
];
const DROPPABLE = new Set(COLUMNS.filter((column) => column.droppable).map((column) => column.id));
const HISTORY_LIMIT = 200;
const HISTORY_SNAPSHOT = 50;
/** Derives the board column for an item; decisions and manual drags win over the AI recommendation. */
export function effectiveColumn(item) {
if (item.decision) return "actioned";
if (item.manualColumn && DROPPABLE.has(item.manualColumn)) return item.manualColumn;
if (item.reviewStatus === "queued") return "reviewing";
if (item.review && RECOMMENDATIONS.includes(item.review.recommendation)) return item.review.recommendation;
return "unreviewed";
}
/** A review is stale when the pull request changed after the review was recorded. */
function reviewIsStale(item) {
if (!item.review?.reviewedAt || !item.updatedAt) return false;
return new Date(item.updatedAt).getTime() > new Date(item.review.reviewedAt).getTime() + 1000;
}
const SYNCED_FIELDS = [
"title",
"url",
"author",
"createdAt",
"updatedAt",
"isDraft",
"baseRefName",
"headRefName",
"headRefOid",
"additions",
"deletions",
"changedFiles",
"reviewDecision",
"mergeStateStatus",
"labels",
"checksState",
"checkCounts",
"checks",
];
export class BoardStore {
constructor({ file, repo }) {
this.file = file;
this.repo = repo;
this.state = { repo, updatedAt: null, items: {}, history: [] };
this.listeners = new Set();
this.writeChain = Promise.resolve();
}
onChange(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async load({ legacyFile } = {}) {
let raw;
let imported = false;
try {
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;
}
/** Serialised atomic write so concurrent mutations cannot interleave or truncate the file. */
persist() {
this.writeChain = this.writeChain.catch(() => {}).then(async () => {
await mkdir(path.dirname(this.file), { recursive: true });
const tmp = `${this.file}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tmp, JSON.stringify(this.state, null, 2), "utf8");
await rename(tmp, this.file);
});
return this.writeChain;
}
emit() {
const snapshot = this.snapshot();
for (const listener of this.listeners) {
try {
listener(snapshot);
} catch {
// A broken subscriber must not break the board.
}
}
}
async commit() {
this.emit();
await this.persist();
return this.snapshot();
}
snapshot() {
const items = Object.values(this.state.items)
.map((item) => ({
...item,
column: effectiveColumn(item),
reviewStale: reviewIsStale(item),
}))
.sort((a, b) => b.number - a.number);
const counts = {};
for (const column of COLUMNS) counts[column.id] = 0;
for (const item of items) counts[item.column] = (counts[item.column] ?? 0) + 1;
return {
repo: this.state.repo,
updatedAt: this.state.updatedAt,
columns: COLUMNS,
recommendations: RECOMMENDATIONS,
items,
counts,
reviewed: items.filter((item) => item.review).length,
history: this.state.history.slice(0, HISTORY_SNAPSHOT),
};
}
getItem(number) {
return this.state.items[String(number)] ?? null;
}
/** Merges freshly fetched pull requests in, dropping anything that is no longer open. */
async syncPullRequests(pullRequests) {
const seen = new Set();
for (const pr of pullRequests) {
const key = String(pr.number);
seen.add(key);
const existing = this.state.items[key] ?? {
number: pr.number,
review: null,
reviewStatus: "none",
queueId: null,
manualColumn: null,
decision: null,
};
const decision = existing.decision;
if ((decision?.kind === "comment" || decision?.kind === "request-changes") && !decision.headRefOid) {
decision.headRefOid = existing.headRefOid ?? pr.headRefOid ?? null;
}
if (
(decision?.kind === "comment" || decision?.kind === "request-changes") &&
decision.headRefOid &&
pr.headRefOid &&
decision.headRefOid !== pr.headRefOid
) {
existing.decision = null;
existing.review = null;
existing.reviewStatus = "none";
existing.queueId = null;
existing.manualColumn = null;
}
for (const field of SYNCED_FIELDS) existing[field] = pr[field];
this.state.items[key] = existing;
}
for (const key of Object.keys(this.state.items)) {
if (!seen.has(key)) delete this.state.items[key];
}
this.state.updatedAt = new Date().toISOString();
return this.commit();
}
/**
* Marks items as queued for review and stamps a fresh queueId on each, so a late
* result from an earlier run can be rejected instead of overwriting newer state.
*/
async markQueued(numbers) {
const previous = [];
const queued = [];
for (const number of numbers) {
const item = this.getItem(number);
if (!item) continue;
previous.push({ number: item.number, reviewStatus: item.reviewStatus, queueId: item.queueId });
item.reviewStatus = "queued";
item.queueId = randomBytes(8).toString("hex");
queued.push({ number: item.number, queueId: item.queueId });
}
await this.commit();
return { previous, queued };
}
async restoreQueueState(previous) {
for (const entry of previous) {
const item = this.getItem(entry.number);
if (!item) continue;
item.reviewStatus = entry.reviewStatus;
item.queueId = entry.queueId;
}
return this.commit();
}
recordReviewInternal(payload) {
const item = this.getItem(payload.number);
if (!item) return { number: payload.number, status: "skipped", reason: "not on the board" };
if (!item.queueId || !payload.queueId || payload.queueId !== item.queueId) {
return { number: payload.number, status: "skipped", reason: "stale review result" };
}
if (!RECOMMENDATIONS.includes(payload.recommendation)) {
return { number: payload.number, status: "skipped", reason: `unknown recommendation "${payload.recommendation}"` };
}
const { number, queueId, recommendation, ...fields } = payload;
item.review = {
recommendation,
...fields,
reviewedAt: new Date().toISOString(),
};
item.reviewStatus = "reviewed";
item.queueId = null;
// A fresh review supersedes an earlier manual bucket override.
item.manualColumn = null;
return { number: item.number, status: "recorded", column: effectiveColumn(item) };
}
async recordReviews(payloads) {
const results = payloads.map((payload) => this.recordReviewInternal(payload));
await this.commit();
return results;
}
async moveItem(number, column) {
const item = this.getItem(number);
if (!item) throw new Error(`Pull request #${number} is not on the board.`);
if (!DROPPABLE.has(column)) throw new Error(`Column "${column}" cannot be set directly.`);
if (item.decision) throw new Error(`Pull request #${number} has already been actioned.`);
if (column === "unreviewed") {
// Returning a card to Unreviewed clears the AI review so the next run picks it up again.
item.review = null;
item.reviewStatus = "none";
item.queueId = null;
item.manualColumn = null;
} else {
item.manualColumn = column;
if (item.reviewStatus === "queued") {
item.reviewStatus = item.review ? "reviewed" : "none";
item.queueId = null;
}
}
return this.commit();
}
async recordDecision(number, decision) {
const item = this.getItem(number);
if (!item) throw new Error(`Pull request #${number} is not on the board.`);
item.decision = {
kind: decision.kind,
comment: decision.comment ?? "",
at: new Date().toISOString(),
status: decision.status ?? null,
headRefOid: item.headRefOid ?? null,
};
this.state.history.unshift({
number: item.number,
title: item.title,
url: item.url,
kind: decision.kind,
comment: decision.comment ?? "",
at: item.decision.at,
status: decision.status ?? null,
});
this.state.history = this.state.history.slice(0, HISTORY_LIMIT);
return this.commit();
}
async clearDecision(number) {
const item = this.getItem(number);
if (!item) throw new Error(`Pull request #${number} is not on the board.`);
item.decision = null;
return this.commit();
}
/** Clear all local triage state; the next refresh repopulates open pull requests. */
async reset() {
this.state = { repo: this.repo, updatedAt: null, items: {}, history: [] };
return this.commit();
}
}