Files
awesome-copilot/.github/extensions/external-plugin-review-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

304 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 = ["straight-reject", "probably-reject", "needs-review", "accept"];
export const COLUMNS = [
{ id: "unreviewed", title: "Unreviewed", droppable: true },
{ id: "reviewing", title: "Reviewing", droppable: false },
{ id: "straight-reject", title: "Straight reject", droppable: true },
{ id: "probably-reject", title: "Probably reject", droppable: true },
{ id: "needs-review", title: "Needs review", droppable: true },
{ id: "accept", title: "Accept", droppable: true },
{ id: "actioned", title: "Actioned", droppable: false },
];
const HISTORY_LIMIT = 200;
function newQueueId() {
return randomBytes(6).toString("base64url");
}
function emptyState(repo) {
return { version: 1, repo, lastRefreshedAt: null, items: {}, history: [] };
}
export function effectiveColumn(item) {
if (item.decision) return "actioned";
if (item.manualColumn) return item.manualColumn;
if (item.reviewStatus === "queued") return "reviewing";
if (item.review?.recommendation) return item.review.recommendation;
return "unreviewed";
}
export class BoardStore {
constructor(filePath, repo) {
this.filePath = filePath;
this.repo = repo;
this.state = emptyState(repo);
this.listeners = new Set();
this.writeChain = Promise.resolve();
}
async load({ legacyFile } = {}) {
let raw;
let imported = false;
try {
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;
}
onChange(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async commit() {
const data = JSON.stringify(this.state, null, 2) + "\n";
this.writeChain = this.writeChain
.catch(() => {})
.then(async () => {
await mkdir(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.tmp`;
await writeFile(tmp, data, "utf8");
await rename(tmp, this.filePath);
});
await this.writeChain;
const snapshot = this.snapshot();
for (const listener of this.listeners) {
try {
listener(snapshot);
} catch {
// listeners are best-effort UI pushes
}
}
}
getItem(number) {
const item = this.state.items[String(number)];
if (!item) throw new Error(`Issue #${number} is not on the board. Refresh first.`);
return item;
}
snapshot() {
const items = Object.values(this.state.items)
.map((item) => ({ ...item, column: effectiveColumn(item) }))
.sort((a, b) => b.number - a.number);
return {
repo: this.state.repo,
lastRefreshedAt: this.state.lastRefreshedAt,
columns: COLUMNS,
items,
history: this.state.history.slice(0, 50),
};
}
async syncIssues(issues) {
const incoming = new Map(issues.map((issue) => [String(issue.number), issue]));
const added = [];
const removed = [];
for (const [key, issue] of incoming) {
const existing = this.state.items[key];
if (existing) {
Object.assign(existing, {
title: issue.title,
author: issue.author,
url: issue.url,
labels: issue.labels,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
});
} else {
this.state.items[key] = {
...issue,
reviewStatus: "unreviewed",
review: null,
manualColumn: null,
decision: null,
addedAt: new Date().toISOString(),
};
added.push(issue.number);
}
}
for (const key of Object.keys(this.state.items)) {
if (!incoming.has(key)) {
removed.push(Number(key));
delete this.state.items[key];
}
}
this.state.lastRefreshedAt = new Date().toISOString();
await this.commit();
return { added, removed, total: incoming.size };
}
pendingReview(numbers) {
const all = Object.values(this.state.items).filter((item) => !item.decision && item.reviewStatus !== "queued");
if (numbers?.length) {
const wanted = new Set(numbers.map(Number));
return all.filter((item) => wanted.has(item.number));
}
return all.filter((item) => item.reviewStatus !== "reviewed");
}
async markQueued(numbers, { rereviewGuidance } = {}) {
const now = new Date().toISOString();
const snapshots = [];
for (const number of numbers) {
const item = this.getItem(number);
snapshots.push({
number,
previous: {
reviewStatus: item.reviewStatus,
queuedAt: item.queuedAt,
queueId: item.queueId,
rereviewGuidance: item.rereviewGuidance,
},
});
item.reviewStatus = "queued";
item.queuedAt = now;
item.queueId = newQueueId();
if (rereviewGuidance !== undefined) item.rereviewGuidance = rereviewGuidance;
else delete item.rereviewGuidance;
}
await this.commit();
return snapshots;
}
async restoreQueueState(snapshots) {
for (const snapshot of snapshots) {
const item = this.state.items[String(snapshot.number)];
if (!item) continue;
const { previous } = snapshot;
item.reviewStatus = previous.reviewStatus;
if (previous.queuedAt === undefined) delete item.queuedAt;
else item.queuedAt = previous.queuedAt;
if (previous.queueId === undefined) delete item.queueId;
else item.queueId = previous.queueId;
if (previous.rereviewGuidance === undefined) delete item.rereviewGuidance;
else item.rereviewGuidance = previous.rereviewGuidance;
}
await this.commit();
}
async recordReviews(reviews) {
const recorded = [];
const skipped = [];
for (const review of reviews) {
const item = this.state.items[String(review.number)];
if (!item) {
skipped.push({ number: review.number, reason: "not on board" });
continue;
}
if (!RECOMMENDATIONS.includes(review.recommendation)) {
skipped.push({ number: review.number, reason: `invalid recommendation ${review.recommendation}` });
continue;
}
if (item.queueId && review.queueId !== item.queueId) {
skipped.push({ number: review.number, reason: "stale review result" });
continue;
}
const { number, ...rest } = review;
item.review = { ...rest, reviewedAt: new Date().toISOString() };
item.reviewStatus = "reviewed";
item.manualColumn = null;
delete item.queuedAt;
delete item.queueId;
delete item.rereviewGuidance;
recorded.push(number);
}
await this.commit();
return { recorded, skipped };
}
async moveItem(number, column) {
const item = this.getItem(number);
if (item.decision) throw new Error(`#${number} has already been actioned.`);
if (column === "unreviewed") {
item.review = null;
item.reviewStatus = "unreviewed";
item.manualColumn = null;
delete item.queuedAt;
delete item.queueId;
} else if (RECOMMENDATIONS.includes(column)) {
item.manualColumn = item.review?.recommendation === column ? null : column;
if (item.reviewStatus === "queued") item.reviewStatus = item.review ? "reviewed" : "unreviewed";
delete item.queueId;
delete item.rereviewGuidance;
} else {
throw new Error(`Items cannot be moved to "${column}".`);
}
await this.commit();
return { number, column: effectiveColumn(item) };
}
async recordPendingDecision(number, pendingDecision) {
const item = this.getItem(number);
item.pendingDecision = pendingDecision;
await this.commit();
}
async resolvePendingDecision(number, status) {
const item = this.state.items[String(number)];
if (!item?.pendingDecision || status.state !== "CLOSED") return null;
const labels = new Set(status.labels ?? []);
const kind = labels.has("approved") ? "approve" : labels.has("rejected") ? "reject" : null;
if (!kind) return null;
const now = new Date().toISOString();
const pendingKind = item.pendingDecision.kind;
const decision =
pendingKind && pendingKind !== kind
? {
kind,
external: true,
comment: "",
body: null,
commentUrl: null,
at: now,
resolvedAt: now,
supersededPending: pendingKind,
}
: { ...item.pendingDecision, kind, resolvedAt: now };
await this.recordDecision(number, decision);
return decision;
}
async pendingDecisionNumbers() {
return Object.values(this.state.items)
.filter((item) => item.pendingDecision && !item.decision)
.map((item) => item.number);
}
async recordDecision(number, decision) {
const item = this.getItem(number);
item.decision = decision;
delete item.pendingDecision;
this.state.history.unshift({
number,
title: item.title,
author: item.author,
kind: decision.kind,
comment: decision.comment ?? "",
aiRecommendation: item.review?.recommendation ?? null,
boardColumn: item.manualColumn ?? item.review?.recommendation ?? null,
at: decision.at,
});
this.state.history = this.state.history.slice(0, HISTORY_LIMIT);
await this.commit();
}
}