/** * The label projection outbox (#201). * * The dispatcher used to write GitHub labels directly, which made the label — * not the store — the crash guard against double dispatch, and let one refused * label write strand an issue (`#184`, `#198`). Dispatch state is now * store-authoritative; every decided label change is an outbox row that this * module projects onto the tracker with retry. * * Both the daemon tick and the `unblock` CLI path drain through the same * `projectLabels` so a 403 no longer takes either down: the op defers with * backoff and the daemon retries it. Per-issue ops apply in id order, which is * what makes a swap atomic — `add` lands before the paired `remove` exactly as * enqueued. */ import { GhRateLimitError, isRateLimitRefusal } from "./tracker/github.ts"; import type { ProjectConfig, Store, Tracker } from "./types.ts"; /** Retry backoff for one failed label op: 30s doubling, capped at 15m (#201). */ export function labelOpBackoffMs(attempts: number): number { return Math.min(30_000 * 2 ** attempts, 15 * 60_000); } function errText(e: unknown): string { return e instanceof Error ? e.message : String(e); } /** * Drain as many due label ops as the tracker will take, in id order. * * A failed op defers with backoff and holds the rest of its issue for the next * pass: a later op for the same issue must not land while an earlier one could * not (that is the atomicity `#184` needed — add-queue-label must not beat * remove-blocked when they were enqueued in that order). Other issues continue. * * Returns what happened so `unblock` can say "queued, the daemon retries" * instead of pretending a deferred op was applied. */ export async function projectLabels( store: Store, tracker: Tracker, project: ProjectConfig, ): Promise<{ applied: number; deferred: number }> { let applied = 0; let deferred = 0; // `pendingLabelOps` returns at most the oldest owed row per issue (it // excludes any row that still has an earlier pending sibling), so a swap's // second op can never land before its first — per-issue atomicity enforced // across calls and processes, not just one pass. Re-querying lets a healthy // chain drain fully in this one call (unblock clears every state label in // one go), while a failing op defers its issue and parks the rest until it // settles. for (;;) { const due = store.pendingLabelOps(project.name, Date.now()); if (due.length === 0) break; let progressed = false; for (const op of due) { try { if (op.op === "add") await tracker.addLabel(op.issue, op.label); else await tracker.removeLabel(op.issue, op.label); store.settleLabelOp(op.id); applied += 1; progressed = true; } catch (err) { // A rate-limit refusal defers WITHOUT counting an attempt: a shared // outage (all of GitHub refusing) is not this op's fault, so it must // not burn the op's backoff escalation (#208). The circuit breaker's // fast-fail (`GhRateLimitError`) is the most common shape — its stderr // says "circuit breaker open", which `isRateLimitRefusal`'s body regex // does not match — so classify it by type first and honour its // `retryAtMs`. Every other failure counts and doubles the next wait. if (err instanceof GhRateLimitError) { store.deferLabelOp(op.id, errText(err), err.retryAtMs, false); } else { const rateLimited = isRateLimitRefusal(err); store.deferLabelOp( op.id, errText(err), Date.now() + labelOpBackoffMs(op.attempts + 1), !rateLimited, ); } deferred += 1; } } // Every remaining op in this pass was deferred (parked behind its backoff), // so re-querying would only re-select the same deferred rows; nothing more // is applicable until a backoff elapses. if (!progressed) break; } return { applied, deferred }; }