/* * Emit on the state that has twice cost hours: a PR that is MERGEABLE and * UNATTENDED. * * Two mergeable, CI-green PRs sat 17 hours; two more sat 40 minutes and 1h48 * on the day the coordinator said it was checking for exactly that. Nothing in * the room said so — a worker went looking and found it. There is no mechanism * that reports "these PRs are mergeable and nobody is acting". * * 6.2 APPLIES HERE TOO: the event is DERIVED from observable state, never a * parallel claim. Everything below is computed from what GitHub actually * reports, and anything it has not answered yet is `unknown`. */ import { normalizeChecks } from "./records.js"; export type PrFactsForAttention = { number: number; state: string; mergeable: string; updatedAt: string; checks: unknown[]; repo?: string; }; export type Attention = | { state: "unattended"; why: string } | { state: "unknown"; why: string } | { state: "attended"; why: string }; /** * UNKNOWN IS NOT UNATTENDED, and this is the clause the coordinator and I have * both been bitten by today. * * `mergeable` is `UNKNOWN` for a while after every push — GitHub computes it * asynchronously — and an empty check rollup is ambiguous between "not yet" and * "never will be". Reading either as "nobody is acting" invents an alert out of * a question GitHub has not answered, which is the fabrication class this fleet * has spent three days removing. */ export function classifyPr(pr: PrFactsForAttention, nowMs: number, quietMs: number): Attention { if (pr.state !== "OPEN") return { state: "attended", why: `#${pr.number} is ${pr.state}` }; if (!pr.mergeable || pr.mergeable === "UNKNOWN") return { state: "unknown", why: `#${pr.number}: GitHub has not computed mergeability yet (frequently the case right after a push). UNKNOWN is not unattended — reporting it would invent an alert from an unanswered question.`, }; if (pr.mergeable === "CONFLICTING") return { state: "attended", why: `#${pr.number} is CONFLICTING — it needs its author, not a gate` }; const checks = normalizeChecks(pr.checks ?? []); if (checks.length === 0) return { state: "unknown", why: `#${pr.number} reports ZERO checks — ambiguous between "not started" and "never will". No checks is not passing checks, and it is not unattended either.`, }; const pending = checks.filter((c) => c.verdict === "pending"); if (pending.length) return { state: "unknown", why: `#${pr.number}: ${pending.length} check(s) still running — not yet a gate's turn` }; const failed = checks.filter((c) => c.verdict === "fail"); if (failed.length) return { state: "attended", why: `#${pr.number} has failing checks (${failed.map((c) => c.name).join(", ")}) — the author's move, not the gate's` }; // Quiet is measured from the PR's own last update. A PR touched a minute ago // is not unattended; its author may still be pushing. const quietFor = nowMs - Date.parse(pr.updatedAt); if (!Number.isFinite(quietFor)) return { state: "unknown", why: `#${pr.number}: unreadable updatedAt '${pr.updatedAt}' — cannot say how long it has waited` }; if (quietFor < quietMs) return { state: "attended", why: `#${pr.number} was updated ${Math.round(quietFor / 60000)}m ago — still moving` }; return { state: "unattended", why: `#${pr.number} is MERGEABLE, all ${checks.length} check(s) pass, and nothing has touched it for ${Math.round(quietFor / 60000)}m`, }; } /** The three states are distinct on purpose; a caller must not collapse them. */ export function partition(prs: PrFactsForAttention[], nowMs: number, quietMs: number) { const out = { unattended: [] as { pr: PrFactsForAttention; why: string }[], unknown: [] as { pr: PrFactsForAttention; why: string }[], attended: 0 }; for (const pr of prs) { const c = classifyPr(pr, nowMs, quietMs); if (c.state === "unattended") out.unattended.push({ pr, why: c.why }); else if (c.state === "unknown") out.unknown.push({ pr, why: c.why }); else out.attended++; } return out; }