import { WatcherQueryError, resolveChecks } from "./github.ts"; import type * as T from "./types.ts"; import { nonEmpty } from "./types.ts"; export function assessGitHubMerge(args: { readonly mergeStateStatus: T.MergeStateStatus; readonly headRollupState: T.RollupState; }): T.GitHubMergeAssessment { if (args.mergeStateStatus === "BLOCKED") { if (args.headRollupState === "ERROR" || args.headRollupState === "FAILURE") return { kind: "refused", mergeStateStatus: args.mergeStateStatus, headRollupState: args.headRollupState, }; return { kind: "allowed", basis: "rollup", mergeStateStatus: args.mergeStateStatus, headRollupState: args.headRollupState, }; } return { kind: "allowed", basis: "merge-state", mergeStateStatus: args.mergeStateStatus, headRollupState: args.headRollupState, }; } async function mergeAssessment( reader: T.GitHubReader, facts: T.PullRequestFacts ) { const commits = await reader.commitRollups(facts.context); const headRollupState = facts.headRefOid === null ? null : (commits.find((commit) => commit.oid === facts.headRefOid)?.state ?? null); return { hadPreviousPassingCi: commits.some( (commit) => commit.oid !== facts.headRefOid && commit.state === "SUCCESS" ), github: assessGitHubMerge({ mergeStateStatus: facts.mergeStateStatus, headRollupState, }), }; } const AUTOMATION_TOKENS = [ "bugbot", "security review", "pr review automation", "review automation", ] as const; export async function readSnapshot(args: { readonly reader: T.GitHubReader; readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) return { kind: "merged", context: args.context, facts }; if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await args.reader.reviewThreads(args.context); const checks = await resolveChecks(args.reader, args.context); const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" ) ); const pending = nonEmpty( checks.checks.filter( (check): check is T.PendingCheck => check.kind === "pending" ) ); let ci: T.CiState; if (failed === null && pending !== null && args.pendingHistory === "omit") ci = { kind: "ci-pending", source: checks.source, all: checks.checks, failed: [], pending, hadPreviousPassingCi: false, }; else { const merge = await mergeAssessment(args.reader, facts); const base = { source: checks.source, all: checks.checks, hadPreviousPassingCi: merge.hadPreviousPassingCi, }; if (failed !== null) ci = { ...base, kind: "ci-failing", failed, pending: pending ?? [], github: merge.github, }; else if (merge.github.kind === "refused") ci = { ...base, kind: "ci-github-rejected", failed: [], pending: pending ?? [], github: merge.github, }; else if (pending !== null) ci = { ...base, kind: "ci-pending", failed: [], pending }; else ci = { ...base, kind: "ci-clean", failed: [], pending: [], github: merge.github, }; } return { kind: "open", context: args.context, facts, threads, ci, reviewAutomationRunning: checks.checks.some( (check) => check.kind === "pending" && AUTOMATION_TOKENS.some((token) => check.name.toLowerCase().includes(token) ) ), }; } const conflictBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => row.kind === "open" && (row.facts.mergeable === "CONFLICTING" || row.facts.mergeStateStatus === "DIRTY" || row.facts.mergeStateStatus === "CONFLICTING") ? { kind: "merge-conflicts", pr: row.context, facts: row.facts } : null; function threadBlocker(row: T.PrSnapshot): T.MergeBlocker | null { if (row.kind !== "open") return null; const threads = nonEmpty(row.threads); return threads === null ? null : { kind: "review-threads", pr: row.context, threads }; } const ciBlocker = (row: T.PrSnapshot): T.MergeBlocker | null => row.kind === "open" && (row.ci.kind === "ci-failing" || row.ci.kind === "ci-github-rejected") ? { kind: "failing-checks", pr: row.context, ci: row.ci } : null; function gateReason( row: T.PrSnapshot, allowDraft: boolean ): T.MergeGateReason | null { if (row.kind === "merged") return null; if (row.kind === "closed") return "closed-without-merge"; if (row.facts.isDraft && !allowDraft) return "draft-pr"; return row.facts.reviewDecision === "CHANGES_REQUESTED" ? "changes-requested" : null; } function gateBlocker( row: T.PrSnapshot, allowDraft: boolean ): T.MergeBlocker | null { const reason = gateReason(row, allowDraft); return reason === null || (reason === "draft-pr" && row.kind === "open" && row.ci.kind === "ci-pending") ? null : { kind: "merge-gate", pr: row.context, reason }; } function readyContribution( row: T.PrSnapshot, allowDraft: boolean ): T.ReadyPr | T.MergedPr | null { if (row.kind === "merged") return { kind: "merged-pr", context: row.context, mergedAt: row.facts.mergedAt, }; if ( row.kind !== "open" || row.ci.kind !== "ci-clean" || row.threads.length !== 0 || conflictBlocker(row) !== null || gateReason(row, allowDraft) !== null ) return null; const reviewDecision = row.facts.reviewDecision; if (reviewDecision === "CHANGES_REQUESTED") return null; return { kind: "ready-pr", context: row.context, proof: { mergeability: "clear", threads: [], ci: row.ci, gate: { state: "OPEN", reviewDecision, draft: row.facts.isDraft ? "draft-allowed" : "not-draft", }, }, }; } export function classifyPr( row: T.PrSnapshot, allowDraft = false ): T.PrDecision { for (const blocker of [ conflictBlocker(row), threadBlocker(row), ciBlocker(row), gateBlocker(row, allowDraft), ]) if (blocker !== null) return { kind: "blocker", blocker }; if (row.kind === "open" && row.ci.kind === "ci-pending") return { kind: "waiting", frontier: row.context, pending: row.ci.pending }; const ready = readyContribution(row, allowDraft); if (ready === null) throw new Error("snapshot has no classified decision"); return ready.kind === "merged-pr" ? { kind: "merged", pr: ready } : { kind: "ready", pr: ready }; } export function selectTierMajorStackDecision( rows: T.NonEmpty, allowDraft = false ): T.StackDecision { for (const tier of [conflictBlocker, threadBlocker, ciBlocker]) for (const row of rows) { const blocker = tier(row); if (blocker !== null) return { kind: "blocker", blocker }; } for (const row of rows) { const blocker = gateBlocker(row, allowDraft); if (blocker !== null) return { kind: "blocker", blocker }; } for (const row of rows) if (row.kind === "open" && row.ci.kind === "ci-pending") return { kind: "waiting", frontier: row.context, pending: row.ci.pending, }; const prs = nonEmpty( rows .map((row) => readyContribution(row, allowDraft)) .filter((row): row is T.ReadyPr | T.MergedPr => row !== null) ); if (prs === null || prs.length !== rows.length) throw new Error("stack has no classified decision"); return { kind: "clear", prs }; } export const queryBackoffSeconds = ( interval: number, failures: number ): number => Math.min(Math.max(interval, 60) * 2 ** (failures - 1), 300); interface Envelope { readonly schemaVersion: 1; readonly sequence: number; readonly observedAt: string; readonly mode: M; } type Payload = V extends unknown ? Omit> : never; type VerdictPayload = Payload; export interface VerdictStamp { (payload: P): Envelope & P; ( payload: P, mode: M2 ): Envelope & P; } export function verdictFactory( clock: WatchClock, mode: M ): VerdictStamp { let sequence = 0; function stamp(payload: P): Envelope & P; function stamp( payload: P, mode: M2 ): Envelope & P; function stamp( payload: P, override?: T.WatchMode ): Envelope & P { return { schemaVersion: 1, sequence: (sequence += 1), observedAt: clock.observedAt(), mode: override ?? mode, ...payload, }; } return stamp; } function blockerVerdict( stamp: VerdictStamp, blocker: T.MergeBlocker ): T.BlockerVerdict { switch (blocker.kind) { case "merge-conflicts": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 2, blocker }); case "review-threads": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 3, blocker }); case "failing-checks": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 4, blocker }); case "merge-gate": return stamp({ kind: "BLOCKER", terminal: true, exitCode: 6, blocker }); default: { const exhaustive: never = blocker; return exhaustive; } } } export function statusQueryVerdict( stamp: VerdictStamp, failures: number, failure: T.QueryFailure ): T.BlockerVerdict { return stamp({ kind: "BLOCKER", terminal: true, exitCode: 7, blocker: { kind: "status-query", failures, failure }, }); } export interface WatchClock { now(): number; observedAt(): string; sleep(seconds: number): Promise; } export interface RunDependencies { readonly reader: T.GitHubReader; readonly clock: WatchClock; readonly emit: (verdict: T.ProgressVerdict) => void; } const deadlinePassed = ( started: number, options: T.PollingOptions, now: number ): boolean => options.timeout > 0 && now - started >= options.timeout; type StepResult = | { readonly kind: "terminal"; readonly verdict: V } | { readonly kind: "sleep"; readonly seconds: number; readonly onDeadline?: () => V; } | { readonly kind: "continue" }; async function pollUntilTerminal(args: { readonly dependencies: RunDependencies; readonly options: T.PollingOptions; readonly stamp: VerdictStamp; readonly step: () => Promise>; }): Promise { let failures = 0; const started = args.dependencies.clock.now(); while (true) { let result: StepResult; try { result = await args.step(); failures = 0; } catch (error) { if (!(error instanceof WatcherQueryError)) throw error; failures += 1; if (!error.failure.retryable || failures >= args.options.maxQueryErrors) return statusQueryVerdict(args.stamp, failures, error.failure); const retryInSeconds = queryBackoffSeconds( args.options.interval, failures ); args.dependencies.emit( args.stamp({ kind: "RETRY", terminal: false, failure: error.failure, consecutiveFailures: failures, retryInSeconds, }) ); if (deadlinePassed(started, args.options, args.dependencies.clock.now())) return args.stamp({ kind: "TIMEOUT", terminal: true, exitCode: 5, reason: { kind: "status-unavailable", failure: error.failure }, }); await args.dependencies.clock.sleep(retryInSeconds); continue; } if (result.kind === "terminal") return result.verdict; if (result.kind === "sleep") { if ( result.onDeadline !== undefined && deadlinePassed(started, args.options, args.dependencies.clock.now()) ) return result.onDeadline(); await args.dependencies.clock.sleep(result.seconds); } } } export async function runSimple(args: { readonly dependencies: RunDependencies; readonly contexts: T.NonEmpty; readonly mode: T.WatchMode; readonly statusOnly: boolean; readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) rows.push( await readSnapshot({ reader: args.dependencies.reader, context, pendingHistory: "include", allowDraft: args.options.allowDraft, }) ); const complete = nonEmpty(rows); if (complete === null) throw new Error("watch context cannot be empty"); if (args.statusOnly) return { kind: "terminal", verdict: stamp({ kind: "STATUS", terminal: true, exitCode: 0, reason: "status-only", rows: complete, }), }; if (args.mode === "queued-stack") throw new Error("queued-stack requires status-only in the simple runner"); if (args.mode === "stack") args.dependencies.emit( stamp( { kind: "STATUS", terminal: false, reason: "poll", rows: complete }, args.mode ) ); const decision = args.mode === "single" ? classifyPr(complete[0], args.options.allowDraft) : selectTierMajorStackDecision(complete, args.options.allowDraft); if (decision.kind === "blocker") return { kind: "terminal", verdict: blockerVerdict(stamp, decision.blocker), }; if (decision.kind === "ready" || decision.kind === "merged") return { kind: "terminal", verdict: stamp( { kind: "READY", terminal: true, exitCode: 0, scope: { kind: "single", pr: decision.pr }, }, args.mode ), }; if (decision.kind === "clear") return { kind: "terminal", verdict: stamp( { kind: "READY", terminal: true, exitCode: 0, scope: { kind: "stack", prs: decision.prs }, }, args.mode ), }; args.dependencies.emit( stamp({ kind: "WAITING", terminal: false, frontier: decision.frontier, reason: { kind: "pending-checks", pending: decision.pending }, }) ); return { kind: "sleep", seconds: args.options.interval, onDeadline: () => stamp({ kind: "TIMEOUT", terminal: true, exitCode: 5, reason: { kind: "pending-checks", pending: decision.pending }, }), }; }; return pollUntilTerminal({ dependencies: args.dependencies, options: args.options, stamp, step, }); } export type QueueWork = | { readonly kind: "whole-stack-sweep"; readonly remaining: T.NonEmpty; } | { readonly kind: "frontier-poll"; readonly frontier: T.PrContext }; export interface QueueState { readonly queue: T.NonEmpty; readonly snapshots: ReadonlyMap; readonly work: QueueWork | null; readonly nextSweepAt: number; readonly frontier: T.PrContext | null; readonly lastWaitKey: string | null; readonly startedAt: number; } export const createQueueState = ( queue: T.NonEmpty, now: number ): QueueState => ({ queue, snapshots: new Map(), work: { kind: "whole-stack-sweep", remaining: queue }, nextSweepAt: now, frontier: null, lastWaitKey: null, startedAt: now, }); const orderedRows = (state: QueueState): T.PrSnapshot[] => state.queue.flatMap((context) => { const row = state.snapshots.get(context.number); return row === undefined ? [] : [row]; }); const activeRows = (state: QueueState): T.PrSnapshot[] => orderedRows(state).filter((row) => row.kind !== "merged"); export function planQueue(state: QueueState, now: number): QueueState { if (state.work !== null) return state; if (state.snapshots.size === 0 || now >= state.nextSweepAt) { const remaining = nonEmpty( state.queue.filter( (context) => state.snapshots.get(context.number)?.kind !== "merged" ) ); if (remaining !== null) return { ...state, work: { kind: "whole-stack-sweep", remaining } }; } const frontier = activeRows(state)[0]?.context; return frontier === undefined ? state : { ...state, work: { kind: "frontier-poll", frontier } }; } export interface QueueSnapshotResult { readonly state: QueueState; readonly completedSweepRows: T.NonEmpty | null; } export function applyQueueSnapshot( state: QueueState, snapshot: T.PrSnapshot, now: number, options: T.PollingOptions ): QueueSnapshotResult { if (state.work === null) throw new Error("queue has no read in flight"); const snapshots = new Map(state.snapshots); snapshots.set(snapshot.context.number, snapshot); const base = { ...state, snapshots }; if (state.work.kind === "frontier-poll") return { state: { ...base, work: null }, completedSweepRows: null }; const [head, ...tail] = state.work.remaining; if (head.number !== snapshot.context.number) throw new Error("snapshot does not match sweep head"); const remaining = nonEmpty(tail); if (remaining !== null) return { state: { ...base, work: { kind: "whole-stack-sweep", remaining } }, completedSweepRows: null, }; const rows = nonEmpty( state.queue.flatMap((context) => { const row = snapshots.get(context.number); return row === undefined ? [] : [row]; }) ); if (rows === null || rows.length !== state.queue.length) throw new Error("sweep completed without every snapshot"); return { state: { ...base, work: null, nextSweepAt: now + options.sweepInterval }, completedSweepRows: rows, }; } export type QueueEvaluation = | { readonly kind: "complete"; readonly state: QueueState; readonly merged: T.NonEmpty; } | { readonly kind: "blocker"; readonly state: QueueState; readonly blocker: T.MergeBlocker; } | { readonly kind: "advance"; readonly state: QueueState; readonly merged: T.PrContext; readonly frontier: T.PrContext; readonly remaining: number; } | { readonly kind: "timeout"; readonly state: QueueState; readonly frontier: T.PrContext; readonly unmergedCount: number; } | { readonly kind: "waiting"; readonly state: QueueState; readonly frontier: T.PrContext; readonly reason: | { readonly kind: "pending-checks"; readonly pending: T.NonEmpty; } | { readonly kind: "merge-queue"; readonly unmergedCount: number }; readonly emit: boolean; }; export function evaluateQueue( state: QueueState, now: number, options: T.PollingOptions ): QueueEvaluation { const active = activeRows(state); if (active.length === 0) { const merged = nonEmpty( orderedRows(state).flatMap((row) => row.kind === "merged" ? [ { kind: "merged-pr" as const, context: row.context, mergedAt: row.facts.mergedAt, }, ] : [] ) ); if (merged === null) throw new Error("empty queue cannot complete"); return { kind: "complete", state, merged }; } const rows = nonEmpty(active); if (rows === null) throw new Error("active queue cannot be empty"); const decision = selectTierMajorStackDecision(rows, options.allowDraft); if (decision.kind === "blocker") return { kind: "blocker", state, blocker: decision.blocker }; const frontier = rows[0].context; if (state.frontier !== null && state.frontier.number !== frontier.number) return { kind: "advance", state: { ...state, frontier, lastWaitKey: null }, merged: state.frontier, frontier, remaining: active.length, }; if (deadlinePassed(state.startedAt, options, now)) return { kind: "timeout", state: { ...state, frontier }, frontier, unmergedCount: active.length, }; const row = rows[0]; const pending = row.kind === "open" && row.ci.kind === "ci-pending" ? row.ci.pending : null; const reason = pending === null ? ({ kind: "merge-queue", unmergedCount: active.length } as const) : ({ kind: "pending-checks", pending } as const); const key = reason.kind === "pending-checks" ? `pending:${frontier.number}:${reason.pending.length}` : `queue:${frontier.number}:${reason.unmergedCount}`; return { kind: "waiting", state: { ...state, frontier, lastWaitKey: key }, frontier, reason, emit: state.lastWaitKey !== key, }; } export async function runQueued(args: { readonly dependencies: RunDependencies; readonly contexts: T.NonEmpty; readonly options: T.PollingOptions; }): Promise { let state = createQueueState(args.contexts, args.dependencies.clock.now()); const stamp = verdictFactory(args.dependencies.clock, "queued-stack"); args.dependencies.emit( stamp({ kind: "QUEUE", terminal: false, queue: args.contexts }) ); const step = async (): Promise> => { state = planQueue(state, args.dependencies.clock.now()); if (state.work === null) { const complete = evaluateQueue( state, args.dependencies.clock.now(), args.options ); if (complete.kind !== "complete") throw new Error("queue has no work while active"); return { kind: "terminal", verdict: stamp({ kind: "COMPLETE", terminal: true, exitCode: 0, queue: state.queue, merged: complete.merged, }), }; } const context = state.work.kind === "whole-stack-sweep" ? state.work.remaining[0] : state.work.frontier; const snapshot = await readSnapshot({ reader: args.dependencies.reader, context, pendingHistory: "omit", allowDraft: args.options.allowDraft, }); const applied = applyQueueSnapshot( state, snapshot, args.dependencies.clock.now(), args.options ); state = applied.state; if (applied.completedSweepRows !== null) args.dependencies.emit( stamp({ kind: "STATUS", terminal: false, reason: "whole-stack-sweep", rows: applied.completedSweepRows, }) ); if (state.work !== null) return { kind: "continue" }; const evaluation = evaluateQueue( state, args.dependencies.clock.now(), args.options ); state = evaluation.state; switch (evaluation.kind) { case "complete": return { kind: "terminal", verdict: stamp({ kind: "COMPLETE", terminal: true, exitCode: 0, queue: state.queue, merged: evaluation.merged, }), }; case "blocker": return { kind: "terminal", verdict: blockerVerdict(stamp, evaluation.blocker), }; case "advance": args.dependencies.emit( stamp({ kind: "ADVANCE", terminal: false, merged: evaluation.merged, frontier: evaluation.frontier, remaining: evaluation.remaining, }) ); return { kind: "continue" }; case "timeout": return { kind: "terminal", verdict: stamp({ kind: "TIMEOUT", terminal: true, exitCode: 5, reason: { kind: "queued-stack", frontier: evaluation.frontier, unmergedCount: evaluation.unmergedCount, }, }), }; case "waiting": if (evaluation.emit) args.dependencies.emit( stamp({ kind: "WAITING", terminal: false, frontier: evaluation.frontier, reason: evaluation.reason, }) ); return { kind: "sleep", seconds: args.options.interval }; default: { const exhaustive: never = evaluation; return exhaustive; } } }; return pollUntilTerminal({ dependencies: args.dependencies, options: args.options, stamp, step, }); }