mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
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
304 lines
11 KiB
JavaScript
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();
|
|
}
|
|
}
|