Files
awesome-copilot/.github/extensions/external-plugin-review-board/lib/state.mjs
T
Aaron PowellandCopilot App 4796642f48 fix(external-plugins): prevent false fast-track approvals
Exclude manually overridden submissions from first-party fast-track until a fresh intake pass clears the override label. Disable individual approval controls while intake is pending.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 821f4796-505e-4fb8-96cf-0b5bbf80a636
2026-10-13 14:24:47 +11:00

358 lines
13 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;
export const FIRST_PARTY_LABEL = "first party";
const MANUAL_INTAKE_OVERRIDE_LABEL = "intake-manually-overridden";
const PENDING_INTAKE_LABELS = ["awaiting-review", "requires-submitter-fixes"];
export function isReadyForApproval(labels) {
return labels.includes("ready-for-review") && !labels.some((label) => PENDING_INTAKE_LABELS.includes(label));
}
// Validated submissions from first-party organizations skip AI triage and land in Accept. Only
// cards without an AI review, queued review, or manual bucket are fast-tracked so maintainer
// choices are kept; dragging a card back to Unreviewed opts it out so it gets a normal AI review.
function applyFirstPartyFastTrack(item) {
if (
!item.labels?.includes(FIRST_PARTY_LABEL) ||
!item.labels?.includes("ready-for-review") ||
item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label)) ||
item.labels?.includes(MANUAL_INTAKE_OVERRIDE_LABEL) ||
item.fastTrackDismissed ||
item.decision ||
item.review ||
item.manualColumn ||
item.reviewStatus === "queued"
) {
return false;
}
item.review = {
recommendation: "accept",
fastTracked: true,
rationale:
"First-party submission (repository owned by a Microsoft/GitHub organization) that passed automated intake validation; fast-tracked to approval.",
suggestedComment: "/approve",
reviewedAt: new Date().toISOString(),
};
item.reviewStatus = "reviewed";
return true;
}
function clearFirstPartyFastTrack(item) {
if (
(item.labels?.includes(FIRST_PARTY_LABEL) &&
item.labels?.includes("ready-for-review") &&
!item.labels?.includes(MANUAL_INTAKE_OVERRIDE_LABEL) &&
!item.labels?.some((label) => PENDING_INTAKE_LABELS.includes(label))) ||
!item.review?.fastTracked ||
item.reviewStatus === "queued"
) {
return;
}
item.review = null;
if (!item.decision && !item.manualColumn) item.reviewStatus = "unreviewed";
}
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: items.map((item) => ({
...item,
reviewEligible: isReadyForApproval(item.labels ?? []),
})),
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 = [];
const fastTracked = [];
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);
}
const item = this.state.items[key];
if (applyFirstPartyFastTrack(item)) fastTracked.push(issue.number);
else clearFirstPartyFastTrack(item);
}
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, fastTracked, total: incoming.size };
}
pendingReview(numbers) {
const all = Object.values(this.state.items).filter(
(item) =>
isReadyForApproval(item.labels ?? []) &&
!item.decision &&
!item.pendingDecision &&
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") {
if (item.review?.fastTracked || item.labels?.includes(FIRST_PARTY_LABEL)) item.fastTrackDismissed = true;
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();
}
}