Skip to main content

omni_dev/
pr_status.rs

1//! GitHub PR check-badge resolution for the worktrees tree (#1337).
2//!
3//! The engine half of the PR badge, mirroring the [`crate::worktrees`] registry /
4//! [`crate::daemon::services::worktrees`] adapter split: this module is pure
5//! resolution — build a query, run `gh`, reduce the reply, cache the result — and
6//! knows nothing about the daemon, the socket, or the tray. The adapter owns the
7//! poll loop and the change-notify.
8//!
9//! # Why this lives in the daemon
10//!
11//! Badges were resolved extension-side ([ADR-0050]), which made cost scale with
12//! the number of open VS Code windows and — the actual bug — meant nothing ever
13//! re-asked GitHub after a badge was first computed. Resolving once in the daemon
14//! and fanning the answer out over the existing subscribe stream makes cost
15//! invariant to window count and lets an unfocused window show live CI state.
16//!
17//! # No credential enters the daemon
18//!
19//! Resolution shells out to `gh api graphql`, so the GitHub token stays inside
20//! `gh` where the user already put it — exactly as ADR-0050 wanted, and following
21//! ADR-0003's "shell out to `gh`/`git` for GitHub operations". This is why moving
22//! resolution daemon-side does **not** widen the worktrees service's no-credential
23//! posture (ADR-0040): the daemon never sees a token.
24//!
25//! # Why one query for everything
26//!
27//! `repository(owner:,name:)` and `ref(qualifiedName:)` take no `first:`/`last:`
28//! argument, so neither is a GraphQL *connection* and neither contributes to the
29//! query's cost. Aliasing them is free: one query covering every (repo, branch)
30//! pair the tree knows about costs **1 point** against the 5,000/hour budget,
31//! independent of how many repos, worktrees, or windows are open. Measured against
32//! `rust-works/omni-dev`: cost 1 up to ~50 branches, 2 at 100, 4 at 200.
33//!
34//! Two shapes are deliberately avoided:
35//!
36//! - `gh pr list --json statusCheckRollup`, which the extension used, is an alias
37//!   for a `commits(last:1)` connection — it adds one request per PR and costs 2.
38//! - `checkSuites { checkRuns { totalCount } }` is a third-level connection and
39//!   costs **11**.
40//!
41//! [ADR-0050]: ../../docs/adrs/adr-0050.md
42
43use std::collections::{BTreeMap, HashMap};
44use std::path::{Path, PathBuf};
45use std::process::Command;
46use std::sync::{Mutex, PoisonError};
47
48use anyhow::{bail, Context, Result};
49use serde::Serialize;
50use serde_json::Value;
51
52/// Environment override for the `gh` binary, for when the daemon runs under
53/// launchd/systemd with a minimal `PATH` that does not contain it. Mirrors
54/// `OMNI_DEV_VSCODE_BIN` for the tray's `code` launcher, and the companion
55/// extension's override of the same name (`editors/vscode/src/gh.ts`).
56const GH_BIN_ENV: &str = "OMNI_DEV_GH_BIN";
57
58/// Absolute paths probed for `gh` when [`GH_BIN_ENV`] is unset, in order. The
59/// daemon cannot rely on `PATH`: launchd hands it a minimal one.
60const GH_BINARY_CANDIDATES: &[&str] = &[
61    "/opt/homebrew/bin/gh",
62    "/usr/local/bin/gh",
63    "/home/linuxbrew/.linuxbrew/bin/gh",
64    "/usr/bin/gh",
65];
66
67/// GitHub check conclusions / status-context states that mean **failed**. Values
68/// are upper-cased before lookup, so these are the canonical GraphQL enum names.
69const FAILURE_STATES: &[&str] = &[
70    "FAILURE",
71    "ERROR",
72    "CANCELLED",
73    "TIMED_OUT",
74    "ACTION_REQUIRED",
75    "STARTUP_FAILURE",
76    "STALE",
77];
78
79/// States that count as **passing**. A skipped/neutral check is non-blocking, so
80/// it passes — matching how `gh pr checks` treats "skipping".
81const SUCCESS_STATES: &[&str] = &["SUCCESS", "NEUTRAL", "SKIPPED"];
82
83/// The rolled-up CI verdict for a pull request.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum PrCheckState {
87    /// Every reported check passed (or was skipped/neutral).
88    Success,
89    /// At least one check failed — failure dominates every other state.
90    Failure,
91    /// At least one check is still running, or reported a state we do not
92    /// recognise. Never a false pass.
93    Pending,
94    /// No checks reported at all. Renders no badge.
95    None,
96}
97
98/// The PR badge shown on a worktree row: which PR heads this branch and how its
99/// CI is doing.
100///
101/// `isDraft` is **camelCase on the wire** — the extension's `PrBadge` inherited
102/// the name from `gh`'s JSON output before badges moved daemon-side, and every
103/// consumer already reads that key. Renaming it to match the payload's otherwise
104/// snake_case convention would silently drop the draft marker.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct PrBadge {
107    /// The PR number, e.g. `1337`.
108    pub number: u64,
109    /// Whether the PR is a draft.
110    #[serde(rename = "isDraft")]
111    pub is_draft: bool,
112    /// The rolled-up CI verdict.
113    pub checks: PrCheckState,
114    /// The PR's web URL, for the open action.
115    pub url: String,
116    /// The commit on the remote branch that [`checks`](Self::checks) describes.
117    ///
118    /// **Not on the wire** — it exists so the snapshot fold can tell a verdict
119    /// computed for *some* commit from one computed for the commit the worktree
120    /// actually has checked out. Without it a badge cannot know it is out of date,
121    /// and the previous head's verdict stands until the next poll (#1337). See
122    /// [`is_stale_for`](Self::is_stale_for).
123    #[serde(skip)]
124    pub head_oid: String,
125}
126
127impl PrBadge {
128    /// Whether this verdict describes a commit other than `head_sha`.
129    ///
130    /// The check is deliberately "different", not "older": we cannot order two
131    /// commits without a walk, and every way they can differ means the same thing —
132    /// **this verdict is not about the commit in front of you**. You pushed and CI
133    /// has not reported yet; you have unpushed work; you are behind the remote.
134    ///
135    /// This is what makes a push invalidate the badge *immediately*, with no network
136    /// call: the cache still holds the previous commit's oid, so the mismatch is
137    /// visible on the very next snapshot. A `None` head (an unborn HEAD) is not
138    /// stale — there is nothing to compare, and such a worktree has no branch and so
139    /// no badge anyway.
140    #[must_use]
141    pub fn is_stale_for(&self, head_sha: Option<&str>) -> bool {
142        head_sha.is_some_and(|sha| sha != self.head_oid)
143    }
144}
145
146/// One (repo, branch) pair to resolve a badge for. Derived from the tree — a repo
147/// contributes a target per worktree that has a branch.
148#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
149pub struct PrTarget {
150    /// The GitHub owner, e.g. `rust-works`.
151    pub owner: String,
152    /// The GitHub repo name, e.g. `omni-dev`.
153    pub name: String,
154    /// The branch checked out in the worktree.
155    pub branch: String,
156}
157
158/// How a **single** rollup entry classifies.
159///
160/// Deliberately narrower than [`PrCheckState`]: an individual check is always
161/// failing, running, or passing. "No checks" is a property of the rollup as a
162/// whole, never of an entry — so rather than carry an impossible `None` case as an
163/// unreachable match arm (as the TypeScript this was ported from did), it simply is
164/// not representable.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166enum EntryState {
167    Failure,
168    Pending,
169    Success,
170}
171
172/// Classifies one `statusCheckRollup` entry.
173///
174/// A `CheckRun` that has not `COMPLETED` is pending regardless of its (null)
175/// conclusion; otherwise the `conclusion` (a `CheckRun`) or `state` (a legacy
176/// `StatusContext`) decides. Anything unrecognised is pending, so a still-resolving
177/// or unknown check never reads as passing.
178fn check_entry_state(entry: &Value) -> EntryState {
179    let status = entry.get("status").and_then(Value::as_str).unwrap_or("");
180    if !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED") {
181        return EntryState::Pending;
182    }
183    // `conclusion` then `state`, each ignored when empty — a completed CheckRun
184    // carries `conclusion`, a StatusContext carries `state`, and neither is set on
185    // an entry still resolving.
186    let raw = entry
187        .get("conclusion")
188        .and_then(Value::as_str)
189        .filter(|s| !s.is_empty())
190        .or_else(|| {
191            entry
192                .get("state")
193                .and_then(Value::as_str)
194                .filter(|s| !s.is_empty())
195        })
196        .unwrap_or("")
197        .to_ascii_uppercase();
198    if FAILURE_STATES.contains(&raw.as_str()) {
199        return EntryState::Failure;
200    }
201    if SUCCESS_STATES.contains(&raw.as_str()) {
202        return EntryState::Success;
203    }
204    EntryState::Pending
205}
206
207/// Reduces a PR's rollup contexts to one verdict: any failing check dominates
208/// (`failure`); else any still-running one (`pending`); else `success`. An empty
209/// rollup means no checks (`none`) — the only way `none` arises, since every entry
210/// classifies as one of the three.
211fn rollup_check_state(contexts: &[Value]) -> PrCheckState {
212    if contexts.is_empty() {
213        return PrCheckState::None;
214    }
215    let mut saw_pending = false;
216    for entry in contexts {
217        match check_entry_state(entry) {
218            EntryState::Failure => return PrCheckState::Failure,
219            EntryState::Pending => saw_pending = true,
220            EntryState::Success => {}
221        }
222    }
223    if saw_pending {
224        return PrCheckState::Pending;
225    }
226    // Every check *that exists* has passed — but more may still be coming, so this
227    // is not yet a pass. See [`suite_still_running`].
228    if contexts.iter().any(suite_still_running) {
229        return PrCheckState::Pending;
230    }
231    // Non-empty, nothing failing, nothing running, every suite terminal ⇒ passed.
232    // (The TypeScript tracked a `sawSuccess` flag here; it could never be false at
233    // this point, so the branch it guarded was dead.)
234    PrCheckState::Success
235}
236
237/// Whether the check **suite** backing this entry is still running.
238///
239/// This is what catches the `needs:`-gate false green. GitHub does not create a
240/// gated job's check run until its dependency completes, so in the window between
241/// the gate passing and the fan-out appearing, every check run that *exists* is
242/// green and the rollup reduces to `success` — GitHub's own aggregate `state` says
243/// `SUCCESS` too. Only the suite knows more jobs are coming.
244///
245/// Reading the suite off a **`CheckRun` in the rollup** (rather than querying
246/// `checkSuites` on the commit) is what makes this safe as well as free. A suite
247/// only appears here by way of a check run it owns, so a suite with **zero** runs —
248/// e.g. codecov leaves one `QUEUED` on every PR, observed still queued after 3.7
249/// days — is never seen and cannot pin a badge yellow forever. No age heuristic, no
250/// app denylist, and no third-level `checkRuns { totalCount }` connection (which
251/// would cost 11 points instead of 1).
252fn suite_still_running(entry: &Value) -> bool {
253    entry
254        .get("checkSuite")
255        .and_then(|suite| suite.get("status"))
256        .and_then(Value::as_str)
257        .is_some_and(|status| !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED"))
258}
259
260/// The GraphQL fragment resolving one branch: its head OID, the rollup contexts
261/// the verdict is reduced from, and the open PR that heads it.
262///
263/// `associatedPullRequests` is read off the **`Ref`**, filtered to `OPEN`, not off
264/// the `Commit`. On a `Commit` it returns whatever PR introduced that commit — on
265/// `main` that is the last *merged* PR, from an unrelated branch, which would paint
266/// a false badge. On a `Ref` it matches on **head**, reproducing the extension's
267/// "first open PR whose `headRefName` is this branch" rule (verified: `main` is the
268/// base of many open PRs and correctly resolves to nothing).
269fn branch_fragment(alias: &str, branch: &str) -> String {
270    // JSON string escaping is valid GraphQL string escaping, so this is safe for
271    // any branch name git permits.
272    let qualified = Value::String(format!("refs/heads/{branch}"));
273    format!(
274        r"{alias}: ref(qualifiedName:{qualified}){{
275      target{{ ...on Commit{{ oid
276        statusCheckRollup{{ contexts(first:100){{ nodes{{
277          __typename
278          ...on CheckRun{{ status conclusion checkSuite{{ status }} }}
279          ...on StatusContext{{ state }}
280        }} }} }}
281      }} }}
282      associatedPullRequests(first:1, states:OPEN){{ nodes{{ number isDraft url }} }}
283    }}"
284    )
285}
286
287/// Maps a query's `(repo alias index, branch alias index)` back to the target it
288/// was built for, so the reply can be read without re-deriving the aliasing.
289type QueryIndex = HashMap<(usize, usize), PrTarget>;
290
291/// Builds the single aliased query for every target, plus the alias→target index
292/// needed to read the reply back. Targets are grouped by repo so each repo appears
293/// once. Returns `None` when there is nothing to ask.
294fn build_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
295    if targets.is_empty() {
296        return None;
297    }
298    // BTreeMap so aliases are assigned deterministically — the query text is then
299    // stable for a stable target set, which keeps it testable.
300    let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
301    for t in targets {
302        by_repo
303            .entry((t.owner.as_str(), t.name.as_str()))
304            .or_default()
305            .push(t);
306    }
307    let mut index = HashMap::new();
308    let mut repos = Vec::new();
309    for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
310        let mut frags = Vec::new();
311        for (bi, target) in branches.iter().enumerate() {
312            frags.push(branch_fragment(&format!("b{bi}"), &target.branch));
313            index.insert((ri, bi), (*target).clone());
314        }
315        let owner = Value::String((*owner).to_string());
316        let name = Value::String((*name).to_string());
317        repos.push(format!(
318            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
319            frags.join("\n")
320        ));
321    }
322    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
323}
324
325/// Reads one resolved `ref` node into a badge. `None` — rendering no badge — for a
326/// branch that does not exist on the remote (never pushed), or that no open PR
327/// heads.
328fn badge_from_ref(node: &Value) -> Option<PrBadge> {
329    let pr = node
330        .get("associatedPullRequests")?
331        .get("nodes")?
332        .as_array()?
333        .first()?;
334    let contexts = node
335        .get("target")
336        .and_then(|t| t.get("statusCheckRollup"))
337        .and_then(|r| r.get("contexts"))
338        .and_then(|c| c.get("nodes"))
339        .and_then(Value::as_array)
340        .cloned()
341        .unwrap_or_default();
342    Some(PrBadge {
343        number: pr.get("number").and_then(Value::as_u64)?,
344        is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
345        checks: rollup_check_state(&contexts),
346        url: pr
347            .get("url")
348            .and_then(Value::as_str)
349            .unwrap_or_default()
350            .to_string(),
351        // The commit this verdict is about — the remote branch head the rollup was
352        // read from, not the PR's own `headRefOid` (the same commit, one fewer field
353        // to ask for).
354        head_oid: node
355            .get("target")
356            .and_then(|t| t.get("oid"))
357            .and_then(Value::as_str)
358            .unwrap_or_default()
359            .to_string(),
360    })
361}
362
363/// Reads the GraphQL reply back into badges, keyed by target.
364///
365/// Best-effort per branch: a missing or malformed node is skipped rather than
366/// sinking the whole poll, matching the tree's "absent field → absent indicator"
367/// degradation.
368fn parse_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, PrBadge> {
369    let mut out = HashMap::new();
370    let Some(data) = body.get("data") else {
371        return out;
372    };
373    for ((ri, bi), target) in index {
374        let node = data
375            .get(format!("r{ri}"))
376            .and_then(|r| r.get(format!("b{bi}")));
377        // A null ref (unpushed branch) is expected, not an error.
378        let Some(node) = node.filter(|n| !n.is_null()) else {
379            continue;
380        };
381        if let Some(badge) = badge_from_ref(node) {
382            out.insert(target.clone(), badge);
383        }
384    }
385    out
386}
387
388/// Resolves `gh`, preferring [`GH_BIN_ENV`], then the first existing well-known
389/// absolute path, then bare `gh` on `PATH`.
390///
391/// Callers should do this **once** (the poller resolves it at spawn) and pass the
392/// result to [`resolve_with`], rather than re-reading the environment per poll.
393#[must_use]
394pub fn resolve_gh_binary() -> PathBuf {
395    resolve_gh_binary_from(std::env::var_os(GH_BIN_ENV), GH_BINARY_CANDIDATES)
396}
397
398/// The testable core of [`resolve_gh_binary`]. Split so the probe order can be
399/// unit-tested without mutating the process environment.
400fn resolve_gh_binary_from(
401    env_override: Option<std::ffi::OsString>,
402    candidates: &[&str],
403) -> PathBuf {
404    if let Some(path) = env_override.filter(|p| !p.is_empty()) {
405        return PathBuf::from(path);
406    }
407    for candidate in candidates {
408        let path = Path::new(candidate);
409        if path.exists() {
410            return path.to_path_buf();
411        }
412    }
413    PathBuf::from("gh")
414}
415
416/// Runs one `gh api graphql` call against `bin`. **Blocking** — callers must be on
417/// a blocking thread, never an async worker.
418fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
419    let output = Command::new(bin)
420        .args(["api", "graphql", "-f"])
421        .arg(format!("query={query}"))
422        .output()
423        .with_context(|| {
424            format!(
425                "failed to run {} (is the GitHub CLI installed?)",
426                bin.display()
427            )
428        })?;
429    if !output.status.success() {
430        let stderr = String::from_utf8_lossy(&output.stderr);
431        bail!("gh api graphql failed: {}", stderr.trim());
432    }
433    serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
434}
435
436/// Resolves a badge for every target in **one** `gh api graphql` call, using the
437/// `gh` at `bin`.
438///
439/// The binary is a parameter rather than resolved here so callers read the
440/// environment **once** (the poller does it at spawn, like its interval) and so
441/// tests inject a stub without mutating the process environment — two parallel
442/// tests pointing one global env var at different fakes race, and the project is
443/// migrating away from test env locks toward injection (#1030).
444///
445/// **Blocking** — run on a blocking thread. Returns only the targets that resolved
446/// to an open PR; a branch with no PR, or not pushed, is simply absent.
447pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrBadge>> {
448    let Some((query, index)) = build_query(targets) else {
449        return Ok(HashMap::new());
450    };
451    let body = run_gh_graphql(bin, &query)?;
452    // A GraphQL 200 can still carry errors; surface them rather than silently
453    // reporting "no badges", which would look identical to "no PRs".
454    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
455        if !errors.is_empty() {
456            bail!("gh api graphql returned errors: {errors:?}");
457        }
458    }
459    Ok(parse_response(&body, &index))
460}
461
462/// The poller-written, snapshot-read badge cache.
463///
464/// A plain `std::Mutex` map: writes come from the poll loop, reads from the tree
465/// snapshot build. The lock is never held across an `.await` — every method takes
466/// it, finishes, and drops it.
467#[derive(Debug, Default)]
468pub struct PrStatusCache {
469    badges: Mutex<HashMap<PrTarget, PrBadge>>,
470}
471
472impl PrStatusCache {
473    /// An empty cache. Until the first poll lands, every lookup misses and the
474    /// tree simply renders no badge.
475    #[must_use]
476    pub fn new() -> Self {
477        Self::default()
478    }
479
480    /// The badge for one (repo, branch), if resolved.
481    #[must_use]
482    pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrBadge> {
483        let key = PrTarget {
484            owner: owner.to_string(),
485            name: name.to_string(),
486            branch: branch.to_string(),
487        };
488        self.lock().get(&key).cloned()
489    }
490
491    /// Replaces the cache wholesale, returning whether anything actually changed.
492    ///
493    /// The bool is load-bearing: the caller bumps the registry's change-notify only
494    /// when it is `true`. Bumping unconditionally would defeat the server's
495    /// diff-and-drop and re-push an identical snapshot to every window on every
496    /// poll — the cost this whole design exists to avoid.
497    pub fn replace(&self, next: HashMap<PrTarget, PrBadge>) -> bool {
498        let mut guard = self.lock();
499        if *guard == next {
500            return false;
501        }
502        *guard = next;
503        true
504    }
505
506    /// Whether any cached badge is still pending — the poller's cadence signal.
507    #[must_use]
508    pub fn any_pending(&self) -> bool {
509        self.lock()
510            .values()
511            .any(|b| b.checks == PrCheckState::Pending)
512    }
513
514    /// Poison-tolerant lock: a panicking holder must not wedge the badge cache,
515    /// which is best-effort decoration.
516    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrBadge>> {
517        self.badges.lock().unwrap_or_else(PoisonError::into_inner)
518    }
519}
520
521#[cfg(test)]
522#[allow(clippy::unwrap_used, clippy::expect_used)]
523mod tests {
524    use super::*;
525    use crate::test_support::shim::{shim_lock, write_exec_script};
526    use serde_json::json;
527    use std::sync::MutexGuard;
528
529    fn target(branch: &str) -> PrTarget {
530        PrTarget {
531            owner: "rust-works".into(),
532            name: "omni-dev".into(),
533            branch: branch.into(),
534        }
535    }
536
537    // --- Reducer: ported verbatim from the extension's github.test.ts so the
538    //     daemon-side move is behaviour-preserving (#1337 PR 2). ---
539
540    #[test]
541    fn rollup_is_none_for_an_empty_rollup() {
542        assert_eq!(rollup_check_state(&[]), PrCheckState::None);
543    }
544
545    #[test]
546    fn rollup_reads_completed_check_run_conclusions() {
547        for (conclusion, want) in [
548            ("SUCCESS", PrCheckState::Success),
549            ("NEUTRAL", PrCheckState::Success),
550            ("SKIPPED", PrCheckState::Success),
551            ("FAILURE", PrCheckState::Failure),
552            ("CANCELLED", PrCheckState::Failure),
553            ("TIMED_OUT", PrCheckState::Failure),
554            ("ACTION_REQUIRED", PrCheckState::Failure),
555            ("STARTUP_FAILURE", PrCheckState::Failure),
556            ("STALE", PrCheckState::Failure),
557        ] {
558            let entry =
559                json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
560            assert_eq!(
561                rollup_check_state(&[entry]),
562                want,
563                "conclusion {conclusion} should be {want:?}"
564            );
565        }
566    }
567
568    #[test]
569    fn rollup_treats_an_incomplete_check_run_as_pending() {
570        // The conclusion is null while running; the status decides.
571        for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
572            let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
573            assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
574        }
575    }
576
577    #[test]
578    fn rollup_reads_status_context_states() {
579        // A legacy StatusContext has no `status`, so `state` decides.
580        for (state, want) in [
581            ("SUCCESS", PrCheckState::Success),
582            ("FAILURE", PrCheckState::Failure),
583            ("ERROR", PrCheckState::Failure),
584            ("PENDING", PrCheckState::Pending),
585            ("EXPECTED", PrCheckState::Pending),
586        ] {
587            let entry = json!({"__typename":"StatusContext","state":state});
588            assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
589        }
590    }
591
592    #[test]
593    fn rollup_never_reads_an_unknown_value_as_a_pass() {
594        // The load-bearing rule: anything unrecognised is pending, never success.
595        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
596        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
597        // A completed-but-unset conclusion likewise.
598        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
599        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
600    }
601
602    #[test]
603    fn rollup_precedence_is_failure_then_pending_then_success() {
604        let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
605        let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
606        let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
607        // Failure dominates everything, in either order.
608        assert_eq!(
609            rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
610            PrCheckState::Failure
611        );
612        assert_eq!(
613            rollup_check_state(&[bad, ok.clone()]),
614            PrCheckState::Failure
615        );
616        // Pending beats success.
617        assert_eq!(
618            rollup_check_state(&[ok.clone(), run]),
619            PrCheckState::Pending
620        );
621        assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
622    }
623
624    // --- Suite awareness: the `needs:`-gate false green ---
625
626    fn run(conclusion: &str, suite: Option<&str>) -> Value {
627        let mut e = json!({
628            "__typename": "CheckRun",
629            "status": "COMPLETED",
630            "conclusion": conclusion,
631        });
632        if let Some(s) = suite {
633            e["checkSuite"] = json!({ "status": s });
634        }
635        e
636    }
637
638    #[test]
639    fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
640        // The exact shape observed on PRs #1294/#1329: every check run that exists
641        // is green — GitHub's own rollup `state` reads SUCCESS — but the CI suite is
642        // still spawning the `needs: gate` fan-out. Reporting success here is the
643        // false ✓ this issue is about.
644        let contexts = vec![
645            run("SUCCESS", Some("IN_PROGRESS")),
646            run("SUCCESS", Some("IN_PROGRESS")),
647        ];
648        assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
649        // QUEUED counts too — the suite exists but has not started its fan-out.
650        assert_eq!(
651            rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
652            PrCheckState::Pending
653        );
654    }
655
656    #[test]
657    fn rollup_is_success_once_every_backing_suite_is_terminal() {
658        let contexts = vec![
659            run("SUCCESS", Some("COMPLETED")),
660            run("SKIPPED", Some("COMPLETED")),
661        ];
662        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
663    }
664
665    #[test]
666    fn rollup_ignores_a_zero_run_zombie_suite() {
667        // codecov leaves a QUEUED suite with **zero** check runs on every PR — one
668        // was still queued after 3.7 days. A rule keyed on "any non-terminal suite"
669        // would pin such a PR yellow forever. Keying on suites reachable *through a
670        // check run* excludes it structurally: with no runs, it never appears in the
671        // rollup at all. So a rollup whose runs all carry terminal suites is green,
672        // even though a zombie suite exists on the commit.
673        let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
674        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
675    }
676
677    #[test]
678    fn rollup_tolerates_entries_without_suite_information() {
679        // A legacy StatusContext has no `checkSuite`, and neither does a CheckRun if
680        // the field is ever absent. Missing suite info must not imply "still
681        // running", or every StatusContext-only repo pins yellow.
682        assert_eq!(
683            rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
684            PrCheckState::Success
685        );
686        assert_eq!(
687            rollup_check_state(&[run("SUCCESS", None)]),
688            PrCheckState::Success
689        );
690        // An empty suite status is not a running suite either.
691        assert_eq!(
692            rollup_check_state(&[run("SUCCESS", Some(""))]),
693            PrCheckState::Success
694        );
695    }
696
697    #[test]
698    fn rollup_failure_still_dominates_a_running_suite() {
699        // A red check is red regardless of what else is still spawning.
700        let contexts = vec![
701            run("FAILURE", Some("IN_PROGRESS")),
702            run("SUCCESS", Some("IN_PROGRESS")),
703        ];
704        assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
705    }
706
707    #[test]
708    fn build_query_asks_for_the_backing_suite_status() {
709        let (query, _) = build_query(&[target("main")]).unwrap();
710        assert!(query.contains("checkSuite{ status }"), "{query}");
711    }
712
713    // --- Query shape ---
714
715    #[test]
716    fn build_query_is_none_without_targets() {
717        assert!(build_query(&[]).is_none());
718    }
719
720    #[test]
721    fn build_query_groups_branches_under_one_repo_alias() {
722        let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
723        // One repo → one `repository(...)` block, two `ref(...)` aliases.
724        assert_eq!(query.matches("repository(owner:").count(), 1);
725        assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
726        assert!(query.contains(r#""refs/heads/main""#), "{query}");
727        assert!(query.contains(r#""refs/heads/feature""#), "{query}");
728        assert_eq!(index.len(), 2);
729    }
730
731    #[test]
732    fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
733        // Guards the semantic trap: `Commit.associatedPullRequests` returns the PR
734        // that *introduced* the commit — on `main`, the last merged PR from an
735        // unrelated branch — which would paint a false badge. It must be read off
736        // the Ref, filtered to OPEN, which matches on head.
737        let (query, _) = build_query(&[target("main")]).unwrap();
738        assert!(
739            query.contains("associatedPullRequests(first:1, states:OPEN)"),
740            "{query}"
741        );
742        // The PR lookup must sit outside the `target{...on Commit{...}}` block.
743        let commit_block = query.find("...on Commit").unwrap();
744        let pr_lookup = query.find("associatedPullRequests").unwrap();
745        assert!(
746            pr_lookup > commit_block,
747            "PR lookup must be on the Ref, after the Commit block"
748        );
749    }
750
751    #[test]
752    fn build_query_separates_distinct_repos() {
753        let a = PrTarget {
754            owner: "o1".into(),
755            name: "r1".into(),
756            branch: "main".into(),
757        };
758        let b = PrTarget {
759            owner: "o2".into(),
760            name: "r2".into(),
761            branch: "main".into(),
762        };
763        let (query, index) = build_query(&[a, b]).unwrap();
764        assert_eq!(query.matches("repository(owner:").count(), 2);
765        assert_eq!(index.len(), 2);
766    }
767
768    #[test]
769    fn build_query_escapes_branch_names() {
770        // Defensive: JSON escaping keeps a quote in a ref name from breaking out of
771        // the GraphQL string literal.
772        let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
773        assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
774    }
775
776    // --- Reply parsing ---
777
778    #[test]
779    fn parse_response_reads_badges_and_skips_absent_refs() {
780        let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
781        let (_, index) = build_query(&targets).unwrap();
782        // Aliases are assigned in BTreeMap order of (owner,name) then input order.
783        let body = json!({"data":{"r0":{
784            "b0": {
785                "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
786                    {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
787                ]}}},
788                "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
789            },
790            // An unpushed branch resolves to a null ref — expected, not an error.
791            "b1": null,
792            // Pushed, but no open PR heads it.
793            "b2": {
794                "target": {"oid":"def","statusCheckRollup":null},
795                "associatedPullRequests":{"nodes":[]}
796            }
797        }}});
798        let out = parse_response(&body, &index);
799        assert_eq!(out.len(), 1, "{out:?}");
800        let badge = out.get(&target("feature")).unwrap();
801        assert_eq!(badge.number, 65);
802        assert!(badge.is_draft);
803        assert_eq!(badge.checks, PrCheckState::Success);
804        assert_eq!(badge.url, "u");
805    }
806
807    #[test]
808    fn parse_response_reads_a_pr_with_no_checks_as_none() {
809        let targets = vec![target("feature")];
810        let (_, index) = build_query(&targets).unwrap();
811        let body = json!({"data":{"r0":{"b0":{
812            "target": {"oid":"abc","statusCheckRollup":null},
813            "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
814        }}}});
815        let out = parse_response(&body, &index);
816        assert_eq!(
817            out.get(&target("feature")).unwrap().checks,
818            PrCheckState::None
819        );
820    }
821
822    #[test]
823    fn parse_response_tolerates_a_missing_data_block() {
824        let (_, index) = build_query(&[target("x")]).unwrap();
825        assert!(parse_response(&json!({}), &index).is_empty());
826    }
827
828    // --- Wire shape ---
829
830    #[test]
831    fn badge_serializes_is_draft_as_camel_case() {
832        // The extension's PrBadge inherited `isDraft` from gh's JSON. Serializing it
833        // as `is_draft` would silently drop the draft marker on every row.
834        let badge = PrBadge {
835            number: 65,
836            is_draft: true,
837            checks: PrCheckState::Pending,
838            url: "u".into(),
839            head_oid: String::new(),
840        };
841        let v = serde_json::to_value(&badge).unwrap();
842        assert_eq!(v["isDraft"], json!(true));
843        assert_eq!(v["checks"], json!("pending"));
844        assert!(v.get("is_draft").is_none(), "{v}");
845    }
846
847    #[test]
848    fn check_state_serializes_lowercase() {
849        for (state, want) in [
850            (PrCheckState::Success, "success"),
851            (PrCheckState::Failure, "failure"),
852            (PrCheckState::Pending, "pending"),
853            (PrCheckState::None, "none"),
854        ] {
855            assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
856        }
857    }
858
859    // --- Binary resolution ---
860
861    #[test]
862    fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
863        assert_eq!(
864            resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
865            PathBuf::from("/custom/gh")
866        );
867        let existing = tempfile::NamedTempFile::new().unwrap();
868        let existing_path = existing.path().to_str().unwrap();
869        assert_eq!(
870            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
871            PathBuf::from(existing_path)
872        );
873        assert_eq!(
874            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
875            PathBuf::from("gh")
876        );
877        // An empty override falls through rather than resolving to "".
878        assert_eq!(
879            resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
880            PathBuf::from("gh")
881        );
882        // The real-env wrapper resolves without panicking.
883        let _ = resolve_gh_binary();
884    }
885
886    // --- resolve_with: the degradation contract ---
887    //
888    // Badges are decoration: a missing, unauthenticated, or failing `gh` must
889    // surface an error to the poller (which backs off and keeps the last good
890    // badges) and never panic or hang. These cover the paths that decide that.
891
892    /// Writes an executable stub standing in for `gh`, printing `stdout` and
893    /// exiting `code`.
894    ///
895    /// Returns the shim lock alongside the path: the caller **must** hold the
896    /// guard until it has finished exec'ing the stub. Writing an executable and
897    /// then `execve`ing it races every other thread that forks — the child
898    /// inherits the still-open writable FD, and the exec fails `ETXTBSY`
899    /// ("Text file busy"). Handing the guard back rather than trusting each test
900    /// to remember `shim_lock()` is deliberate: this helper originally open-coded
901    /// the write and skipped the lock, and the race duly fired in CI (#642, #1344).
902    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
903        let guard = shim_lock();
904        let path = dir.join("fake-gh");
905        write_exec_script(
906            &path,
907            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
908        );
909        (path, guard)
910    }
911
912    #[test]
913    fn resolve_with_asks_nothing_for_no_targets() {
914        // No branches to resolve → no subprocess at all. The binary is deliberately
915        // bogus: if this spawned anything it would error instead of returning empty.
916        let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
917        assert!(out.is_empty());
918    }
919
920    #[test]
921    fn resolve_with_errors_when_gh_is_missing() {
922        // The common real case: `gh` not installed, or absent from launchd's
923        // minimal PATH.
924        let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
925        let msg = format!("{err:#}");
926        assert!(msg.contains("failed to run"), "{msg}");
927        assert!(msg.contains("GitHub CLI"), "{msg}");
928    }
929
930    #[test]
931    fn resolve_with_errors_on_a_nonzero_exit() {
932        // e.g. `gh auth login` never run: gh exits non-zero and explains on stderr.
933        let dir = tempfile::tempdir().unwrap();
934        let (bin, _shim) = fake_gh(dir.path(), "", 1);
935        let err = resolve_with(&bin, &[target("main")]).unwrap_err();
936        assert!(
937            format!("{err:#}").contains("gh api graphql failed"),
938            "{err:#}"
939        );
940    }
941
942    #[test]
943    fn resolve_with_errors_on_unparseable_output() {
944        let dir = tempfile::tempdir().unwrap();
945        let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
946        let err = resolve_with(&bin, &[target("main")]).unwrap_err();
947        assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
948    }
949
950    #[test]
951    fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
952        // A GraphQL 200 can still carry errors (a bad field, a rate limit). Reading
953        // that as an empty result would be indistinguishable from "no open PRs" and
954        // would silently blank every badge, so it must be an error.
955        let dir = tempfile::tempdir().unwrap();
956        let (bin, _shim) = fake_gh(
957            dir.path(),
958            r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
959            0,
960        );
961        let err = resolve_with(&bin, &[target("main")]).unwrap_err();
962        let msg = format!("{err:#}");
963        assert!(msg.contains("returned errors"), "{msg}");
964        assert!(msg.contains("rate limit"), "{msg}");
965    }
966
967    #[test]
968    fn resolve_with_ignores_an_empty_errors_array() {
969        // Some responses carry `errors: []`; that is a success, not a failure.
970        let dir = tempfile::tempdir().unwrap();
971        let (bin, _shim) = fake_gh(
972            dir.path(),
973            r#"{"errors":[],"data":{"r0":{"b0":{
974                "target":{"oid":"a","statusCheckRollup":null},
975                "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
976            0,
977        );
978        let out = resolve_with(&bin, &[target("main")]).unwrap();
979        assert_eq!(out.get(&target("main")).unwrap().number, 9);
980    }
981
982    #[test]
983    fn resolve_with_reads_a_real_reply_end_to_end() {
984        let dir = tempfile::tempdir().unwrap();
985        let (bin, _shim) = fake_gh(
986            dir.path(),
987            r#"{"data":{"r0":{"b0":{
988                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
989                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
990                ]}}},
991                "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
992            0,
993        );
994        let out = resolve_with(&bin, &[target("main")]).unwrap();
995        let badge = out.get(&target("main")).unwrap();
996        assert_eq!(badge.number, 42);
997        assert!(badge.is_draft);
998        assert_eq!(badge.checks, PrCheckState::Failure);
999    }
1000
1001    // --- Cache ---
1002
1003    #[test]
1004    fn cache_replace_reports_whether_anything_changed() {
1005        let cache = PrStatusCache::new();
1006        let badge = PrBadge {
1007            number: 1,
1008            is_draft: false,
1009            checks: PrCheckState::Pending,
1010            url: "u".into(),
1011            head_oid: String::new(),
1012        };
1013        let mut map = HashMap::new();
1014        map.insert(target("f"), badge);
1015
1016        // First write is a change.
1017        assert!(cache.replace(map.clone()));
1018        // An identical write is not — this is what keeps the poller from re-pushing
1019        // an unchanged snapshot to every window on every tick.
1020        assert!(!cache.replace(map.clone()));
1021
1022        // A changed verdict is a change.
1023        let mut moved = map.clone();
1024        moved.get_mut(&target("f")).unwrap().checks = PrCheckState::Success;
1025        assert!(cache.replace(moved));
1026        // Emptying is a change.
1027        assert!(cache.replace(HashMap::new()));
1028        assert!(!cache.replace(HashMap::new()));
1029    }
1030
1031    #[test]
1032    fn cache_get_and_any_pending() {
1033        let cache = PrStatusCache::new();
1034        assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1035        assert!(!cache.any_pending());
1036
1037        let mut map = HashMap::new();
1038        map.insert(
1039            target("f"),
1040            PrBadge {
1041                number: 1,
1042                is_draft: false,
1043                checks: PrCheckState::Pending,
1044                url: "u".into(),
1045                head_oid: String::new(),
1046            },
1047        );
1048        cache.replace(map.clone());
1049        assert_eq!(cache.get("rust-works", "omni-dev", "f").unwrap().number, 1);
1050        assert!(cache.any_pending());
1051        // A miss on a branch we never resolved.
1052        assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1053
1054        map.get_mut(&target("f")).unwrap().checks = PrCheckState::Success;
1055        cache.replace(map);
1056        assert!(!cache.any_pending());
1057    }
1058}