import { describe, expect, test, beforeEach } from "bun:test" import { spawnSync } from "node:child_process" import { mkdtempSync, writeFileSync, readFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" // Regression tests for the ce-babysit-pr pr-snapshot claim->act->confirm engine. // Exercised via --fetch-file (no live PR), following the tests/*-validator.test.ts // spawnSync pattern. Locks in the ce-code-review fixes: crash-safety, needs-human // silencing + open_needs_human visibility, checks_terminal, key-collision, null-head. const SCRIPT = path.join(import.meta.dir, "..", "skills", "ce-babysit-pr", "scripts", "pr-snapshot") function fetchFile(dir: string, name: string, obj: unknown): string { const p = path.join(dir, name) writeFileSync(p, JSON.stringify(obj)) return p } function snapshot(stateDir: string, fetch: string): any { const r = spawnSync( "python3", [SCRIPT, "snapshot", "--pr", "1", "--repo", "o/r", "--state-dir", stateDir, "--fetch-file", fetch], { encoding: "utf8" }, ) expect(r.status, r.stderr).toBe(0) return JSON.parse(r.stdout) } function mark(stateDir: string, args: string[]): void { // Default the at-mark baseline fetch to empty threads (-> lazy first-observation baseline, no gh // call); a test exercising at-mark capture passes its own --fetch-file, which we don't override. const extra = args.includes("--fetch-file") ? [] : ["--fetch-file", fetchFile(path.dirname(stateDir), "mark-empty.json", { threads: [] })] const r = spawnSync("python3", [SCRIPT, "mark", "--state-dir", stateDir, ...args, ...extra], { encoding: "utf8" }) expect(r.status, r.stderr).toBe(0) } function watch(stateDir: string, fetch: string, extra: string[] = []): any { const r = spawnSync( "python3", [SCRIPT, "watch", "--pr", "1", "--repo", "o/r", "--state-dir", stateDir, "--fetch-file", fetch, "--interval", "0.1", "--max-runtime", "1", ...extra], { encoding: "utf8" }, ) expect(r.status, r.stderr).toBe(0) return JSON.parse(r.stdout.trim().split("\n").pop()!) // the wake sentinel is the final line } const FAILING = { pr_state: "OPEN", mergeable: "MERGEABLE", merge_state_status: "BLOCKED", review_decision: "REVIEW_REQUIRED", head_sha: "s1", url: "http://x/1", checks: [{ key: "CI/test", name: "test", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" }], threads: [{ thread_id: "T1", last_comment_id: "C1", last_comment_at: "t1" }], } describe("ce-babysit-pr pr-snapshot engine", () => { let dir: string let state: string beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), "prsnap-")) state = path.join(dir, "state") }) test("first snapshot: thread + failing check are actionable; checks terminal", () => { const d = snapshot(state, fetchFile(dir, "a.json", FAILING)) expect(d.counts.threads).toBe(1) expect(d.counts.ci).toBe(1) expect(d.has_failing_checks).toBe(true) expect(d.checks_terminal).toBe(true) }) test("crash-safety: un-marked items stay actionable on the next tick", () => { const f = fetchFile(dir, "a.json", FAILING) const first = snapshot(state, f) const second = snapshot(state, f) expect(second.counts.threads).toBe(first.counts.threads) expect(second.counts.ci).toBe(first.counts.ci) }) test("needs-human thread: silenced despite the resolver's own reply moving identity, but stays visible via open_needs_human", () => { snapshot(state, fetchFile(dir, "a.json", FAILING)) mark(state, ["--thread", "T1", "--disposition", "needs-human"]) // The resolver posts decision_context, moving the thread's last-comment identity. const replied = { ...FAILING, threads: [{ thread_id: "T1", last_comment_id: "C2", last_comment_at: "t2" }] } const d = snapshot(state, fetchFile(dir, "b.json", replied)) expect(d.counts.threads).toBe(0) // no re-actionize (the P1 fix) expect(d.open_needs_human).toBe(1) // still blocks merge-ready }) test("mark --check silences it; a new head SHA re-actionizes", () => { const f = fetchFile(dir, "a.json", FAILING) snapshot(state, f) mark(state, ["--check", "CI/test"]) expect(snapshot(state, f).counts.ci).toBe(0) const newHead = { ...FAILING, head_sha: "s2" } expect(snapshot(state, fetchFile(dir, "c.json", newHead)).counts.ci).toBe(1) }) test("checks_terminal is false while a check is IN_PROGRESS; all_checks_ok stays false", () => { const inprog = { ...FAILING, checks: [{ key: "CI/test", name: "test", status: "IN_PROGRESS", conclusion: null, details_url: "u" }], } const d = snapshot(state, fetchFile(dir, "ip.json", inprog)) expect(d.checks_terminal).toBe(false) expect(d.all_checks_ok).toBe(false) expect(d.has_failing_checks).toBe(false) }) test("clean + terminal + approved: all_checks_ok true, mergeStateStatus passthrough, no open needs-human", () => { const clean = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [{ key: "CI/test", name: "test", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" }], threads: [], } const d = snapshot(state, fetchFile(dir, "cl.json", clean)) expect(d.all_checks_ok).toBe(true) expect(d.checks_terminal).toBe(true) expect(d.merge_state_status).toBe("CLEAN") expect(d.open_needs_human).toBe(0) }) test("colliding check keys are disambiguated (both failing checks surface, neither shadows)", () => { const collide = { ...FAILING, checks: [ { key: "test", name: "test", status: "COMPLETED", conclusion: "FAILURE", details_url: "u1" }, { key: "test", name: "test", status: "COMPLETED", conclusion: "FAILURE", details_url: "u2" }, ], } expect(snapshot(state, fetchFile(dir, "co.json", collide)).counts.ci).toBe(2) }) test("transient null head falls back to the last known head — no ci_dispatched wipe / re-dispatch thrash", () => { const f = fetchFile(dir, "a.json", FAILING) snapshot(state, f) mark(state, ["--check", "CI/test"]) const nullHead = { ...FAILING, head_sha: null } const d = snapshot(state, fetchFile(dir, "nh.json", nullHead)) expect(d.head_changed).toBe(false) expect(d.counts.ci).toBe(0) // still silenced }) // --- trajectory: deterministic cross-tick facts for non-convergence detection --- const GREEN_CHECK = { key: "CI/test", name: "test", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } const RED_CHECK = { key: "CI/test", name: "test", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" } test("check recurrence: fail -> clear -> fail on a NEW head increments recur (ping-pong signal)", () => { snapshot(state, fetchFile(dir, "r1.json", { ...FAILING, head_sha: "s1", checks: [RED_CHECK] })) snapshot(state, fetchFile(dir, "r2.json", { ...FAILING, head_sha: "s2", checks: [GREEN_CHECK] })) const d = snapshot(state, fetchFile(dir, "r3.json", { ...FAILING, head_sha: "s3", checks: [RED_CHECK] })) expect(d.trajectory.check_recur_max).toBe(1) expect(d.trajectory.recurring_checks).toEqual([{ key: "CI/test", recur: 1 }]) }) test("same-head flapping is NOT recurrence (flaky, not ping-pong)", () => { const f = { ...FAILING, head_sha: "s1" } snapshot(state, fetchFile(dir, "f1.json", { ...f, checks: [RED_CHECK] })) snapshot(state, fetchFile(dir, "f2.json", { ...f, checks: [GREEN_CHECK] })) const d = snapshot(state, fetchFile(dir, "f3.json", { ...f, checks: [RED_CHECK] })) expect(d.trajectory.check_recur_max).toBe(0) }) test("review backlog trend rises and new-thread arrivals are counted (treadmill signal)", () => { const th = (ids: string[]) => ids.map((id) => ({ thread_id: id, last_comment_id: `c-${id}`, last_comment_at: id })) snapshot(state, fetchFile(dir, "t1.json", { ...FAILING, checks: [], threads: th(["T1"]) })) snapshot(state, fetchFile(dir, "t2.json", { ...FAILING, checks: [], threads: th(["T1", "T2"]) })) const d = snapshot(state, fetchFile(dir, "t3.json", { ...FAILING, checks: [], threads: th(["T1", "T2", "T3", "T4"]) })) expect(d.trajectory.unresolved_trend).toBe("rising") expect(d.trajectory.new_threads_this_tick).toBe(2) // T3, T4 are new this tick expect(d.trajectory.unresolved_threads).toBe(4) }) test("check_recur_max does not stay elevated after the recurring check leaves CI (stale-key prune)", () => { snapshot(state, fetchFile(dir, "p1.json", { ...FAILING, head_sha: "s1", checks: [RED_CHECK] })) snapshot(state, fetchFile(dir, "p2.json", { ...FAILING, head_sha: "s2", checks: [GREEN_CHECK] })) expect(snapshot(state, fetchFile(dir, "p3.json", { ...FAILING, head_sha: "s3", checks: [RED_CHECK] })).trajectory.check_recur_max).toBe(1) // CI/test is gone from the run (renamed/removed); its recurrence must not linger. const other = { key: "CI/other", name: "other", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } const d = snapshot(state, fetchFile(dir, "p4.json", { ...FAILING, head_sha: "s4", checks: [other] })) expect(d.trajectory.check_recur_max).toBe(0) }) test("heads_since_progress climbs on a persistent failure across heads, but resets on progressive migration", () => { // Same check red across three new heads with nothing clearing = a stall. snapshot(state, fetchFile(dir, "s1.json", { ...FAILING, head_sha: "h1", checks: [RED_CHECK], threads: [] })) expect(snapshot(state, fetchFile(dir, "s2.json", { ...FAILING, head_sha: "h2", checks: [RED_CHECK], threads: [] })).trajectory.heads_since_progress).toBe(1) expect(snapshot(state, fetchFile(dir, "s3.json", { ...FAILING, head_sha: "h3", checks: [RED_CHECK], threads: [] })).trajectory.heads_since_progress).toBe(2) // A different check now fails (A cleared, B appeared) = progressive migration, not a stall -> reset. const other = { key: "CI/other", name: "other", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" } expect(snapshot(state, fetchFile(dir, "s4.json", { ...FAILING, head_sha: "h4", checks: [other], threads: [] })).trajectory.heads_since_progress).toBe(0) }) test("parking a thread counts as progress: it leaves the non-parked problem set, so no-progress resets", () => { const withThread = (headSha: string) => ({ ...FAILING, head_sha: headSha, checks: [RED_CHECK], threads: [{ thread_id: "T1", last_comment_id: "c1", last_comment_at: "t1" }], }) snapshot(state, fetchFile(dir, "pk1.json", withThread("h1"))) // problems: {CI/test, T1} mark(state, ["--thread", "T1", "--disposition", "needs-human"]) // New head, CI/test still red, T1 now parked (excluded from problems) -> total drops 2->1 = a new low. const d = snapshot(state, fetchFile(dir, "pk2.json", withThread("h2"))) expect(d.open_needs_human).toBe(1) expect(d.trajectory.heads_since_progress).toBe(0) // progress was made (a problem left the set), despite the head change }) test("a rerun (IN_PROGRESS) is not a clear — no false recurrence when it fails again", () => { snapshot(state, fetchFile(dir, "ir1.json", { ...FAILING, head_sha: "s1", checks: [RED_CHECK] })) const rerun = { ...FAILING, head_sha: "s2", checks: [{ key: "CI/test", name: "test", status: "IN_PROGRESS", conclusion: null, details_url: "u" }] } snapshot(state, fetchFile(dir, "ir2.json", rerun)) const d = snapshot(state, fetchFile(dir, "ir3.json", { ...FAILING, head_sha: "s3", checks: [RED_CHECK] })) expect(d.trajectory.check_recur_max).toBe(0) }) test("mark --disposition open re-actionizes a parked needs-human thread (the re-open path)", () => { const f = fetchFile(dir, "ro.json", FAILING) snapshot(state, f) mark(state, ["--thread", "T1", "--disposition", "needs-human"]) expect(snapshot(state, f).open_needs_human).toBe(1) // parked, not actionable mark(state, ["--thread", "T1", "--disposition", "open"]) const d = snapshot(state, f) expect(d.counts.threads).toBe(1) // re-opened -> actionable again expect(d.open_needs_human).toBe(0) }) test("a dispatched thread reactivates when a later reviewer comment moves its identity, but not on our own reply (acted_identity baseline)", () => { // The false-green fix: a dispatched-but-unresolved thread with fresh reviewer activity must // return to actionable, or it stays hidden from counts.threads and lets merge-ready fire. const sd = path.join(dir, "react") const thr = (cid: string) => ({ pr_state: "OPEN", mergeable: "MERGEABLE", merge_state_status: "CLEAN", review_decision: null, head_sha: "s1", url: "http://x/1", checks: [], threads: [{ thread_id: "T1", last_comment_id: cid, last_comment_at: cid }], }) snapshot(sd, fetchFile(dir, "r1.json", thr("C1"))) // open -> actionable mark(sd, ["--thread", "T1", "--disposition", "dispatched"]) // first post-action observation adopts the current identity (our reply) as baseline -> silenced expect(snapshot(sd, fetchFile(dir, "r2.json", thr("C1"))).counts.threads).toBe(0) // same identity on a later tick -> still silenced (our own reply does not re-trigger) expect(snapshot(sd, fetchFile(dir, "r3.json", thr("C1"))).counts.threads).toBe(0) // a genuine reviewer reply moves the identity to C2 -> reactivated expect(snapshot(sd, fetchFile(dir, "r4.json", thr("C2"))).counts.threads).toBe(1) }) test("a needs-human thread reactivates when a human answers it (a later reply past the baseline), not on our own decision_context reply", () => { const sd = path.join(dir, "nhreact") const thr = (cid: string) => ({ ...FAILING, checks: [], threads: [{ thread_id: "T1", last_comment_id: cid, last_comment_at: cid }], }) snapshot(sd, fetchFile(dir, "nh1.json", thr("C1"))) mark(sd, ["--thread", "T1", "--disposition", "needs-human"]) // first observation after our decision_context reply (C2) -> adopt as baseline, stays parked const d1 = snapshot(sd, fetchFile(dir, "nh2.json", thr("C2"))) expect(d1.counts.threads).toBe(0) expect(d1.open_needs_human).toBe(1) // still parked, blocks merge-ready // a human replies past the baseline (C3) -> reactivated to actionable, no longer parked const d2 = snapshot(sd, fetchFile(dir, "nh3.json", thr("C3"))) expect(d2.counts.threads).toBe(1) // reopened -> the loop reprocesses with the human's input expect(d2.open_needs_human).toBe(0) }) test("blocked_external waits for other running checks — does not fire while a check is still IN_PROGRESS", () => { const RUNNING = { key: "CI/b", name: "b", status: "IN_PROGRESS", conclusion: null, details_url: "u" } const GREEN = { key: "CI/a", name: "a", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } // awaiting approval + a still-running check -> NOT blocked_external yet (that check could fail) const running = { ...FAILING, threads: [], checks: [RUNNING], awaiting_approval: 1 } expect(snapshot(path.join(dir, "be1"), fetchFile(dir, "be1.json", running)).blocked_external).toBe(false) // awaiting approval + all other checks terminal -> blocked_external const terminal = { ...FAILING, threads: [], checks: [GREEN], awaiting_approval: 1 } expect(snapshot(path.join(dir, "be2"), fetchFile(dir, "be2.json", terminal)).blocked_external).toBe(true) }) test("a dispatched (handled) top-level comment does not inflate heads_since_progress across heads", () => { // A handled comment never drops out of the fetch, so counting it as an open problem would keep // heads_since_progress climbing forever and falsely trip non-convergence on unrelated later work. const sd = path.join(dir, "stall") const fb = (head: string) => ({ ...FAILING, head_sha: head, checks: [], threads: [], feedback: [{ id: "IC_1", kind: "comment", author: "r", edit_id: "h" }], }) snapshot(sd, fetchFile(dir, "st1.json", fb("s1"))) // IC_1 open -> a problem mark(sd, ["--comment", "IC_1", "--disposition", "dispatched", "--acted-edit-id", "h"]) const d = snapshot(sd, fetchFile(dir, "st2.json", fb("s2"))) // dispatched + head moved -> handled, progress expect(d.trajectory.heads_since_progress).toBe(0) }) test("a watch poll does not consume new_threads_this_tick — the agent's tick still sees the new arrival", () => { // The watch's waking poll persists change-detection state but must NOT roll the trajectory, or it // marks the just-arrived thread "seen" and the agent's real tick reads 0 new arrivals — hiding a // review-bot treadmill from the non-convergence trigger. const sd = path.join(dir, "trajwatch") const noThreads = { ...FAILING, checks: [], threads: [] } snapshot(sd, fetchFile(dir, "tw1.json", noThreads)) // agent tick: baseline, no threads const withThread = { ...FAILING, checks: [], threads: [{ thread_id: "T1", last_comment_id: "C1", last_comment_at: "C1" }] } expect(watch(sd, fetchFile(dir, "tw2.json", withThread)).reason).toBe("actionable") // a poll wakes on the new thread // the agent's real tick then still counts T1 as newly arrived (the poll didn't mark it seen) expect(snapshot(sd, fetchFile(dir, "tw3.json", withThread)).trajectory.new_threads_this_tick).toBe(1) }, 15000) test("heads_since_progress counts head moves across AGENT ticks even when a poll observed the new head first (C2)", () => { const sd = path.join(dir, "hspwatch") const failAt = (head: string) => ({ ...FAILING, head_sha: head, threads: [], checks: [{ key: "CI/x", name: "x", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" }] }) snapshot(sd, fetchFile(dir, "hw1.json", failAt("s1"))) // agent tick: persistent failure at head s1 watch(sd, fetchFile(dir, "hw2.json", failAt("s2"))) // a poll observes+persists head s2 (no trajectory roll) const d = snapshot(sd, fetchFile(dir, "hw3.json", failAt("s2"))) // agent tick at s2 expect(d.trajectory.heads_since_progress).toBe(1) // head moved s1->s2 between agent ticks; not starved by the poll }, 15000) test("check recurrence catches a CLEAR observed only on a watch poll (C1)", () => { const sd = path.join(dir, "recurwatch") const RED = { key: "CI/x", name: "x", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" } const GREEN = { key: "CI/x", name: "x", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } snapshot(sd, fetchFile(dir, "rw1.json", { ...FAILING, head_sha: "s1", threads: [], checks: [RED] })) // fail h1 watch(sd, fetchFile(dir, "rw2.json", { ...FAILING, head_sha: "s2", threads: [], checks: [GREEN] })) // a poll observes the CLEAR const d = snapshot(sd, fetchFile(dir, "rw3.json", { ...FAILING, head_sha: "s3", threads: [], checks: [RED] })) // fail h3 expect(d.trajectory.check_recur_max).toBe(1) // fail -> clear(seen only on a poll) -> fail = recurrence }, 15000) test("--reset-session restarts the budget clock so a resumed watch is not instantly over-budget", () => { const sd = path.join(dir, "sess") snapshot(sd, fetchFile(dir, "se1.json", FAILING)) // creates state with started_at = now // simulate resuming days later against persisted state: backdate started_at const statePath = path.join(sd, "state.json") const st = JSON.parse(readFileSync(statePath, "utf8")) st.started_at = "2020-01-01T00:00:00+00:00" writeFileSync(statePath, JSON.stringify(st)) // without reset -> session_seconds is huge (measured from 2020) expect(snapshot(sd, fetchFile(dir, "se2.json", FAILING)).session_seconds).toBeGreaterThan(1_000_000) // with --reset-session -> the clock restarts, session_seconds ~0 const r = spawnSync("python3", [SCRIPT, "snapshot", "--pr", "1", "--repo", "o/r", "--state-dir", sd, "--fetch-file", fetchFile(dir, "se3.json", FAILING), "--reset-session"], { encoding: "utf8" }) expect(r.status, r.stderr).toBe(0) expect(JSON.parse(r.stdout).session_seconds).toBeLessThan(10) }) test("clearing a fork approval gate is movement (resets the settle clock so merge-ready waits for check-runs)", () => { const sd = path.join(dir, "appr") const gated = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [], threads: [], awaiting_approval: 1 } snapshot(sd, fetchFile(dir, "ap1.json", gated)) // first tick expect(snapshot(sd, fetchFile(dir, "ap2.json", gated)).changed_this_tick).toBe(false) // stable gate, no movement // approval clears (no check-runs created yet) -> registered as movement so quiet resets expect(snapshot(sd, fetchFile(dir, "ap3.json", { ...gated, awaiting_approval: 0 })).changed_this_tick).toBe(true) }) test("mark --thread captures the acted baseline at mark time (closes the reviewer-reply race)", () => { const sd = path.join(dir, "atmark") const thr = (cid: string) => ({ ...FAILING, checks: [], threads: [{ thread_id: "T1", last_comment_id: cid, last_comment_at: cid }], }) snapshot(sd, fetchFile(dir, "am1.json", thr("C1"))) // our decision_context reply is C2; marking WITH the current fetch captures C2 as the baseline now mark(sd, ["--thread", "T1", "--disposition", "needs-human", "--fetch-file", fetchFile(dir, "am2.json", thr("C2"))]) // a reviewer reply that raced in (C3) before the next snapshot -> reactivated, not swallowed as baseline const d = snapshot(sd, fetchFile(dir, "am3.json", thr("C3"))) expect(d.counts.threads).toBe(1) // C3 != the C2 baseline captured at mark -> reopened expect(d.open_needs_human).toBe(0) }) test("mark --comment with --acted-edit-id captures the baseline at mark time (closes the edit race)", () => { const sd = path.join(dir, "cmark") const fb = (edit: string) => ({ ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [], threads: [], feedback: [{ id: "IC_1", kind: "comment", author: "reviewer", edit_id: edit }], }) snapshot(sd, fetchFile(dir, "cm1.json", fb("h1"))) // mark dispatched with the snapshot-time edit_id (h1) as the explicit baseline (our reply never edits it) mark(sd, ["--comment", "IC_1", "--disposition", "dispatched", "--acted-edit-id", "h1"]) // an edit that races in (h2) before the next snapshot -> reactivated, not swallowed as baseline expect(snapshot(sd, fetchFile(dir, "cm2.json", fb("h2"))).counts.comments).toBe(1) }) test("a dispatched thread reactivates when an EARLIER comment is edited (same last_comment_id, bumped last_comment_at)", () => { // fetch_threads sets last_comment_at = max edit/create time across the whole thread, so an edit // to an earlier comment (last_comment_id unchanged) still moves the identity and reopens it. const sd = path.join(dir, "editearlier") const thr = (at: string) => ({ ...FAILING, checks: [], threads: [{ thread_id: "T1", last_comment_id: "R1", last_comment_at: at }], }) snapshot(sd, fetchFile(dir, "ee1.json", thr("t1"))) mark(sd, ["--thread", "T1", "--disposition", "dispatched"]) // lazy baseline expect(snapshot(sd, fetchFile(dir, "ee2.json", thr("t1"))).counts.threads).toBe(0) // baseline (R1,t1) -> silenced // reviewer edits an earlier comment: last_comment_id stays R1 but the thread's max edit time bumps expect(snapshot(sd, fetchFile(dir, "ee3.json", thr("t2"))).counts.threads).toBe(1) // reactivated }) test("a dispatched top-level comment reactivates when its body is edited (edit_id changes), not on our reply", () => { // A non-actionable wrapper marked dispatched, later edited to add an actionable request, must // return to actionable — our own reply is a separate top-level comment and never edits it. const sd = path.join(dir, "editfb") const fb = (edit: string) => ({ ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [], threads: [], feedback: [{ id: "IC_1", kind: "comment", author: "reviewer", edit_id: edit }], }) snapshot(sd, fetchFile(dir, "e1.json", fb("h1"))) // actionable mark(sd, ["--comment", "IC_1", "--disposition", "dispatched"]) expect(snapshot(sd, fetchFile(dir, "e2.json", fb("h1"))).counts.comments).toBe(0) // same body -> silenced expect(snapshot(sd, fetchFile(dir, "e3.json", fb("h2"))).counts.comments).toBe(1) // edited -> reactivated }) test("a fork-PR workflow awaiting maintainer approval blocks 'all_checks_ok' and flags blocked_external", () => { const gated = { ...FAILING, merge_state_status: "UNSTABLE", review_decision: "", checks: [{ key: "Track", name: "Track", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" }], threads: [], awaiting_approval: 1, // real CI hasn't run — awaiting a base-repo maintainer's approval } const d = snapshot(state, fetchFile(dir, "aa.json", gated)) expect(d.checks_awaiting_approval).toBe(1) expect(d.has_failing_checks).toBe(false) expect(d.all_checks_ok).toBe(false) // not "ok" — the gated CI is invisible to the rollup expect(d.blocked_external).toBe(true) }) test("an empty statusCheckRollup (no check-runs yet) is not ok — checks_present false blocks a pipeline false-success", () => { const noChecks = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [], threads: [] } const d = snapshot(state, fetchFile(dir, "nc.json", noChecks)) expect(d.checks_present).toBe(false) expect(d.all_checks_ok).toBe(false) // no observed checks -> not "ok"; the pipeline stop must not exit-success expect(d.checks_terminal).toBe(true) // vacuously terminal on an empty set — exactly why checks_present is needed }) test("_resolve_repo_ref parses the host from the PR URL so gh api targets GHE, not github.com", () => { const r = spawnSync( "python3", [ "-c", `from importlib.machinery import SourceFileLoader; ` + `m=SourceFileLoader('prs', ${JSON.stringify(SCRIPT)}).load_module(); ` + `print(m._resolve_repo_ref('', 'https://ghe.acme.com/o/r/pull/5')); ` + `print(m._host_args('ghe.acme.com')); print(m._host_args(None))`, ], { encoding: "utf8" }, ) expect(r.status, r.stderr).toBe(0) const lines = r.stdout.trim().split("\n") expect(lines[0]).toBe("('o', 'r', 'ghe.acme.com')") expect(lines[1]).toBe("['--hostname', 'ghe.acme.com']") expect(lines[2]).toBe("[]") }) test("cross-stream alternation: ci-only then review-only then ci-only ticks flip (churn signal)", () => { const th = (ids: string[]) => ids.map((id) => ({ thread_id: id, last_comment_id: `c-${id}`, last_comment_at: id })) snapshot(state, fetchFile(dir, "a1.json", { ...FAILING, head_sha: "s1", checks: [RED_CHECK], threads: [] })) snapshot(state, fetchFile(dir, "a2.json", { ...FAILING, head_sha: "s2", checks: [GREEN_CHECK], threads: th(["T1"]) })) const d = snapshot(state, fetchFile(dir, "a3.json", { ...FAILING, head_sha: "s3", checks: [RED_CHECK], threads: [] })) expect(d.trajectory.stream_alternations).toBe(2) // ci -> review -> ci }) test("non-thread feedback: a top-level comment / review body is actionable, mark --comment silences it, needs-human blocks ready", () => { const withFeedback = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", checks: [GREEN_CHECK], threads: [], feedback: [ { id: "IC_1", kind: "comment", author: "reviewer" }, { id: "PRR_1", kind: "review", author: "coderabbit", state: "COMMENTED" }, ], } const f = fetchFile(dir, "fb.json", withFeedback) const d = snapshot(state, f) expect(d.counts.comments).toBe(2) // both surfaced as actionable review items with no inline thread expect(d.actionable.comments.map((c: any) => c.id).sort()).toEqual(["IC_1", "PRR_1"]) mark(state, ["--comment", "IC_1", "--disposition", "dispatched"]) mark(state, ["--comment", "PRR_1", "--disposition", "needs-human"]) const d2 = snapshot(state, f) expect(d2.counts.comments).toBe(0) // dispatched item silenced; needs-human item parked, not actionable expect(d2.open_needs_human).toBe(1) // parked comment blocks merge-ready just like a parked thread }) test("_extract_feedback excludes the PR author and pure CI/status bots (incl [bot] suffix), keeps review bots and non-empty review bodies", () => { const v = { author: { login: "me" }, comments: [ { id: "c-me", author: { login: "me" }, body: "my own note" }, // author -> excluded { id: "c-cov", author: { login: "codecov[bot]" }, body: "coverage -0.1%" }, // CI bot -> excluded { id: "c-rev", author: { login: "octo-reviewer" }, body: "please rename this" }, // human -> kept { id: "c-empty", author: { login: "octo-reviewer" }, body: " " }, // empty -> excluded ], reviews: [ { id: "r-cr", author: { login: "coderabbitai[bot]" }, body: "overall LGTM but see nits", state: "COMMENTED" }, // review bot -> kept { id: "r-empty", author: { login: "octo-reviewer" }, body: "", state: "APPROVED" }, // empty body -> excluded ], } const r = spawnSync( "python3", [ "-c", `import json; from importlib.machinery import SourceFileLoader; ` + `m=SourceFileLoader('prs', ${JSON.stringify(SCRIPT)}).load_module(); ` + `print(json.dumps([f['id'] for f in m._extract_feedback(json.loads(${JSON.stringify(JSON.stringify(v))}))]))`, ], { encoding: "utf8" }, ) expect(r.status, r.stderr).toBe(0) expect(JSON.parse(r.stdout.trim()).sort()).toEqual(["c-rev", "r-cr"]) }) test("watch: wakes on actionable backlog, terminal, and merge-ready-after-settle; times out on clean-not-settled", () => { const GREEN = { key: "CI/test", name: "test", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } // actionable backlog (FAILING has an unresolved thread + a failing check) -> wake expect(watch(path.join(dir, "w1"), fetchFile(dir, "wa.json", FAILING)).reason).toBe("actionable") // terminal PR -> wake regardless of backlog const term = fetchFile(dir, "wt.json", { ...FAILING, pr_state: "CLOSED", threads: [], checks: [] }) expect(watch(path.join(dir, "w2"), term).reason).toBe("terminal") // clean + green but not yet settled (settle 300 > quiet ~0) -> keep watching -> times out const clean = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", threads: [], checks: [GREEN] } const cf = fetchFile(dir, "wc.json", clean) expect(watch(path.join(dir, "w3"), cf, ["--settle-seconds", "300"]).reason).toBe("max-runtime") // same clean state with a zero settle window -> merge-ready wake expect(watch(path.join(dir, "w4"), cf, ["--settle-seconds", "0"]).reason).toBe("merge-ready") }, 15000) // spawns 4 watch subprocesses incl. a max-runtime timeout -> explicit timeout over Bun's 5s default test("watch: an in-progress review signal blocks the merge-ready wake regardless of quiet time", () => { // "Looks ready" is signal-gated: a green/CLEAN PR with a review still in flight (review_in_progress) // must NOT wake merge-ready even with a zero settle window — time is not the gate. const GREEN = { key: "CI/test", name: "test", status: "COMPLETED", conclusion: "SUCCESS", details_url: "u" } const base = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", threads: [], checks: [GREEN] } const inprog = fetchFile(dir, "rip1.json", { ...base, review_in_progress: true }) expect(watch(path.join(dir, "rip1"), inprog, ["--settle-seconds", "0"]).reason).toBe("max-runtime") const nosig = fetchFile(dir, "rip2.json", { ...base, review_in_progress: false }) expect(watch(path.join(dir, "rip2"), nosig, ["--settle-seconds", "0"]).reason).toBe("merge-ready") }, 15000) test("watch: a no-check MERGEABLE/CLEAN PR still reaches merge-ready (the >=1-check guard is pipeline-only)", () => { // A repo with no configured checks: all_checks_ok is false (no observed check), but the // interactive merge-ready wake must still fire for a CLEAN/MERGEABLE PR with no backlog. const nochecks = { ...FAILING, merge_state_status: "CLEAN", review_decision: "APPROVED", threads: [], checks: [] } expect(watch(path.join(dir, "nc1"), fetchFile(dir, "nc1.json", nochecks), ["--settle-seconds", "0"]).reason).toBe("merge-ready") }, 15000) test("watch: a dispatched terminal-red check present at arm is a standing residual — kept watching, not re-woken", () => { // A failing check ce-debug marked dispatched leaves counts.ci == 0 while has_failing_checks stays // true. It was already surfaced when it was dispatched, so it is in the watch's arm-time baseline // and must NOT re-wake the loop (that was the pre-gating behavior); the watch keeps running for // other streams. `blocked-failing` only fires on a *later* transition to terminal-red (e.g. a // rerun completing red) — the same wake-on-new path the parked-needs-human test exercises. const red = { ...FAILING, threads: [], checks: [{ key: "CI/test", name: "test", status: "COMPLETED", conclusion: "FAILURE", details_url: "u" }] } const rf = fetchFile(dir, "wbf.json", red) const sd = path.join(dir, "wbf") snapshot(sd, rf) // the failing check is actionable on this first tick mark(sd, ["--check", "CI/test"]) // now dispatched -> counts.ci == 0, terminal-red residual, already surfaced expect(watch(sd, rf).reason).toBe("max-runtime") }, 15000) test("watch: a parked needs-human does not wake or end the loop — it keeps watching the other streams", () => { // The stop-vs-residual fix: a standing needs-human present at arm time must NOT re-wake the // detector (that would busy-wake / falsely terminate the self-sustaining watch); the watch keeps // polling for new work and only wakes when something genuinely new arrives. const sd = path.join(dir, "nhwatch") const base = (extra: any[] = []) => ({ pr_state: "OPEN", mergeable: "MERGEABLE", merge_state_status: "CLEAN", review_decision: null, head_sha: "s1", url: "http://x/1", checks: [], threads: [{ thread_id: "T1", last_comment_id: "C1", last_comment_at: "C1" }, ...extra], }) snapshot(sd, fetchFile(dir, "nhw1.json", base())) mark(sd, ["--thread", "T1", "--disposition", "needs-human"]) // parked needs-human, nothing else actionable -> keeps watching, times out (does NOT wake needs-human) expect(watch(sd, fetchFile(dir, "nhw2.json", base())).reason).toBe("max-runtime") // a new actionable thread arrives while the needs-human stays parked -> wakes on the new work const withNew = fetchFile(dir, "nhw3.json", base([{ thread_id: "T2", last_comment_id: "D1", last_comment_at: "D1" }])) expect(watch(sd, withNew).reason).toBe("actionable") }, 15000) })