Files
awesome-copilot/.github/extensions/external-plugin-review-board/lib/state.mjs
T

293 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() {
try {
const raw = await readFile(this.filePath, "utf8");
const parsed = JSON.parse(raw);
this.state = { ...emptyState(this.repo), ...parsed, repo: parsed.repo ?? this.repo };
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
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();
}
}