// Review-wait liveness policy, kept as a pure module (no env, no I/O) so it is trivially // testable — mirrors app/rounds.ts. `service.ts` builds the fleet-wide REVIEW_WAIT_TIMEOUT and // REVIEW_NUDGE_MS on top of these, and the process's timer catch is seeded with the validated // ISO-8601 duration at submit. // // Two knobs govern the review-wait watchdog: // • the *timeout* — an ISO-8601 duration handed to the process's `wait-review-timeout` timer // catch (the far side of the event-based-gateway race against `readiness-ready`). If no fresh // review arrives within it, the loop escalates to a human instead of hanging forever. // • the *nudge cooldown* — how long the poller waits between automatic Copilot re-requests for // one waiting PR, so a re-request that Copilot dismisses is retried without hammering the API. /** Default review-wait timeout (ISO-8601 duration): how long the loop waits for a fresh review * before the timer arm of the event-based gateway fires and it escalates to a human. */ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT30M"; // A pragmatic ISO-8601 duration matcher: requires a leading `P`, at least one component, and a // `T` before any time components (with at least one time component after it). Good enough to // reject an obviously-malformed env value before it is baked into a timer expression the engine // would fail to interpret; not a full grammar (we don't need fractional seconds here). const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/; /** True when `raw` is a well-formed ISO-8601 duration under {@link isoDuration}'s grammar — the strict * predicate an operator submission door uses to REJECT a malformed duration (400) rather than silently * fall back to a default. Derives from the same {@link ISO_DURATION} grammar so the accept/reject * decision can never drift from the normalise-or-default one. Case-insensitive (`pt2h` is valid). */ export function isValidIsoDuration(raw: string): boolean { return ISO_DURATION.test(raw.trim().toUpperCase()); } /** Validate an ISO-8601 duration string for a BPMN timer's ``, falling back to * `def` when the value is absent, blank, or malformed — a bad env value must never deploy an * uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`). * This is the single canonical duration validator; per-timer policies (review-wait, escalation * SLA) derive their env-driven value from it rather than re-implementing the grammar. */ export function isoDuration(raw: string | undefined, def: string): string { const s = (raw ?? "").trim().toUpperCase(); return s !== "" && ISO_DURATION.test(s) ? s : def; } /** Convert an ISO-8601 duration to whole milliseconds, sharing {@link isoDuration}'s validation and * grammar so a value's ms budget and its BPMN timer string can never derive from two parsers. `raw` * is validated (and normalised) through {@link isoDuration} first, falling back to `def` when it is * absent/blank/malformed. Calendar components carry no anchor date in a bare duration, so `Y`/`M` * use pragmatic fixed lengths (365d / 30d); the realistic inputs here are seconds…days. */ export function isoDurationToMs(raw: string | undefined, def: string): number { const m = ISO_DURATION.exec(isoDuration(raw, def)); if (!m) return 0; const n = (g?: string): number => (g ? Number.parseInt(g, 10) : 0); return ( n(m[1]) * 31_536_000_000 + // years (365d) n(m[2]) * 2_592_000_000 + // months (30d) n(m[3]) * 604_800_000 + // weeks n(m[4]) * 86_400_000 + // days n(m[6]) * 3_600_000 + // hours n(m[7]) * 60_000 + // minutes n(m[8]) * 1000 // seconds ); } /** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value * is absent, blank, or malformed. Thin wrapper over {@link isoDuration}. */ export function reviewWaitTimeout( raw: string | undefined, def: string = DEFAULT_REVIEW_WAIT_TIMEOUT, ): string { return isoDuration(raw, def); } /** Default cooldown (minutes) between automatic Copilot re-request nudges for one waiting PR. * Kept comfortably below the review-wait timeout default so several nudges are attempted before * the loop escalates. */ export const DEFAULT_REVIEW_NUDGE_MINUTES = 5; /** Upper bound on the nudge cooldown — a runaway value could park a genuinely-stalled PR for days * with no re-request. Mirrors the ceiling discipline in rounds.ts. */ export const MAX_REVIEW_NUDGE_MINUTES = 24 * 60; /** Coerce the operator-supplied nudge cooldown (env `NANO_PR_REVIEW_NUDGE_MINUTES`, minutes) into * a sane positive integer. Absent / blank / non-numeric / zero / negative / NaN fall back to * `fallback`; values above the ceiling are clamped down (and so is an oversized `fallback`, so it * can never bypass the ceiling). */ export function clampNudgeMinutes( value: unknown, fallback: number = DEFAULT_REVIEW_NUDGE_MINUTES, ): number { const safeFallback = Number.isFinite(fallback) ? Math.min(Math.max(Math.trunc(fallback), 1), MAX_REVIEW_NUDGE_MINUTES) : DEFAULT_REVIEW_NUDGE_MINUTES; const n = typeof value === "number" ? value : Number(String(value ?? "").trim()); if (!Number.isFinite(n)) return safeFallback; const i = Math.trunc(n); if (i < 1) return safeFallback; return Math.min(i, MAX_REVIEW_NUDGE_MINUTES); } /** Is the latest Copilot review STALE relative to the PR's current HEAD? (issue #799) * * A review is stale when it was submitted against a commit the head has since moved past — its * advisories describe code that no longer exists, so the convergence gate must NOT block/escalate * against it and the poller must NOT unpark the loop on it; instead a fresh review of the current * HEAD must be solicited and gated on. Staleness is a plain SHA inequality. * * Fails SAFE to "not stale" when either SHA is unknown (`null`/`undefined`/blank): GitHub did not * carry a `commit_id` for the review, or the head could not be read. Without both SHAs we cannot * prove the review predates the head, so we must not fabricate a stale verdict that would loop the * loop re-soliciting forever — the review-wait timeout and the round cap remain the safety nets. */ export function isReviewStale( reviewCommitId: string | null | undefined, headSha: string | null | undefined, ): boolean { const rev = (reviewCommitId ?? "").trim(); const head = (headSha ?? "").trim(); if (rev === "" || head === "") return false; return rev !== head; }