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//! # The explicit negative (#1370)
42//!
43//! Resolution is tri-state per target, not binary. "No open PR heads this branch"
44//! is a *successful* answer ([`PrResolution::NoPr`]), distinct from "never
45//! resolved" (absent from the cache). Absence used to carry both meanings, which
46//! left a client unable to tell "checked, none" from "old daemon that never
47//! checked" — so every window's degraded `gh pr list` fallback re-fired per
48//! window × repo × 60s forever, burning the very GraphQL budget this module
49//! exists to protect. A **failed** poll still reports nothing: negatives are only
50//! ever minted from a successfully parsed reply.
51//!
52//! [ADR-0050]: ../../docs/adrs/adr-0050.md
53
54use std::collections::{BTreeMap, HashMap, HashSet};
55use std::path::{Path, PathBuf};
56use std::sync::{Mutex, PoisonError};
57
58use anyhow::{bail, Context, Result};
59use serde::{Deserialize, Serialize};
60use serde_json::Value;
61
62/// Environment override for the `gh` binary, for when the daemon runs under
63/// launchd/systemd with a minimal `PATH` that does not contain it. Mirrors
64/// `OMNI_DEV_VSCODE_BIN` for the tray's `code` launcher, and the companion
65/// extension's override of the same name (`editors/vscode/src/gh.ts`).
66const GH_BIN_ENV: &str = "OMNI_DEV_GH_BIN";
67
68/// Absolute paths probed for `gh` when [`GH_BIN_ENV`] is unset, in order. The
69/// daemon cannot rely on `PATH`: launchd hands it a minimal one.
70const GH_BINARY_CANDIDATES: &[&str] = &[
71    "/opt/homebrew/bin/gh",
72    "/usr/local/bin/gh",
73    "/home/linuxbrew/.linuxbrew/bin/gh",
74    "/usr/bin/gh",
75];
76
77/// GitHub check conclusions / status-context states that mean **failed**. Values
78/// are upper-cased before lookup, so these are the canonical GraphQL enum names.
79const FAILURE_STATES: &[&str] = &[
80    "FAILURE",
81    "ERROR",
82    "CANCELLED",
83    "TIMED_OUT",
84    "ACTION_REQUIRED",
85    "STARTUP_FAILURE",
86    "STALE",
87];
88
89/// States that count as **passing**. A skipped/neutral check is non-blocking, so
90/// it passes — matching how `gh pr checks` treats "skipping".
91const SUCCESS_STATES: &[&str] = &["SUCCESS", "NEUTRAL", "SKIPPED"];
92
93/// The rolled-up CI verdict for a pull request.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum PrCheckState {
97    /// Every reported check passed (or was skipped/neutral).
98    Success,
99    /// At least one check failed — failure dominates every other state.
100    Failure,
101    /// At least one check is still running, or reported a state we do not
102    /// recognise. Never a false pass.
103    Pending,
104    /// No checks reported at all. Renders no badge.
105    None,
106}
107
108/// The PR badge shown on a worktree row: which PR heads this branch and how its
109/// CI is doing.
110///
111/// `isDraft` is **camelCase on the wire** — the extension's `PrBadge` inherited
112/// the name from `gh`'s JSON output before badges moved daemon-side, and every
113/// consumer already reads that key. Renaming it to match the payload's otherwise
114/// snake_case convention would silently drop the draft marker.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct PrBadge {
117    /// The PR number, e.g. `1337`.
118    pub number: u64,
119    /// Whether the PR is a draft.
120    #[serde(rename = "isDraft")]
121    pub is_draft: bool,
122    /// The rolled-up CI verdict.
123    pub checks: PrCheckState,
124    /// The PR's web URL, for the open action.
125    pub url: String,
126    /// The commit on the remote branch that [`checks`](Self::checks) describes.
127    ///
128    /// **Not on the wire** — it exists so the snapshot fold can tell a verdict
129    /// computed for *some* commit from one computed for the commit the worktree
130    /// actually has checked out. Without it a badge cannot know it is out of date,
131    /// and the previous head's verdict stands until the next poll (#1337). See
132    /// [`is_stale_for`](Self::is_stale_for).
133    #[serde(skip)]
134    pub head_oid: String,
135}
136
137impl PrBadge {
138    /// Whether this verdict describes a commit other than `head_sha`.
139    ///
140    /// The check is deliberately "different", not "older": we cannot order two
141    /// commits without a walk, and every way they can differ means the same thing —
142    /// **this verdict is not about the commit in front of you**. You pushed and CI
143    /// has not reported yet; you have unpushed work; you are behind the remote.
144    ///
145    /// This is what makes a push invalidate the badge *immediately*, with no network
146    /// call: the cache still holds the previous commit's oid, so the mismatch is
147    /// visible on the very next snapshot. A `None` head (an unborn HEAD) is not
148    /// stale — there is nothing to compare, and such a worktree has no branch and so
149    /// no badge anyway.
150    #[must_use]
151    pub fn is_stale_for(&self, head_sha: Option<&str>) -> bool {
152        head_sha.is_some_and(|sha| sha != self.head_oid)
153    }
154}
155
156/// One (repo, branch) pair to resolve a badge for. Derived from the tree — a repo
157/// contributes a target per worktree that has a branch.
158///
159/// `Serialize`/`Deserialize` so the daemon can persist the resolved badge cache
160/// across restarts (#1389, fix 4); on the tree wire the target is implicit in the
161/// worktree row, so this shape appears only in that `0600` cache file.
162#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
163pub struct PrTarget {
164    /// The GitHub owner, e.g. `rust-works`.
165    pub owner: String,
166    /// The GitHub repo name, e.g. `omni-dev`.
167    pub name: String,
168    /// The branch checked out in the worktree.
169    pub branch: String,
170}
171
172/// The outcome for one **successfully checked** target (#1370).
173///
174/// [`NoPr`](Self::NoPr) deliberately carries no `head_oid`: a negative has no
175/// verdict to be stale against, and carrying the remote head would turn every
176/// push into a `NoPr(a) → NoPr(b)` map change — spuriously bumping the
177/// change-notify that [`PrStatusCache::replace`] exists to gate. A negative that
178/// *is* out of date (a PR was just opened) is corrected by the poller's
179/// `moved`-triggered immediate re-poll instead.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum PrResolution {
182    /// An open PR heads the branch; here is its badge.
183    Pr(PrBadge),
184    /// The branch was checked against GitHub and **no open PR** heads it —
185    /// including a branch never pushed. Serialized as `pr_none: true` on the
186    /// tree wire, so a client can tell "checked, none" from "not resolved" and
187    /// keep its degraded fallback quiet.
188    NoPr,
189}
190
191/// How a **single** rollup entry classifies.
192///
193/// Deliberately narrower than [`PrCheckState`]: an individual check is always
194/// failing, running, or passing. "No checks" is a property of the rollup as a
195/// whole, never of an entry — so rather than carry an impossible `None` case as an
196/// unreachable match arm (as the TypeScript this was ported from did), it simply is
197/// not representable.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum EntryState {
200    Failure,
201    Pending,
202    Success,
203}
204
205/// Classifies one `statusCheckRollup` entry.
206///
207/// A `CheckRun` that has not `COMPLETED` is pending regardless of its (null)
208/// conclusion; otherwise the `conclusion` (a `CheckRun`) or `state` (a legacy
209/// `StatusContext`) decides. Anything unrecognised is pending, so a still-resolving
210/// or unknown check never reads as passing.
211fn check_entry_state(entry: &Value) -> EntryState {
212    let status = entry.get("status").and_then(Value::as_str).unwrap_or("");
213    if !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED") {
214        return EntryState::Pending;
215    }
216    // `conclusion` then `state`, each ignored when empty — a completed CheckRun
217    // carries `conclusion`, a StatusContext carries `state`, and neither is set on
218    // an entry still resolving.
219    let raw = entry
220        .get("conclusion")
221        .and_then(Value::as_str)
222        .filter(|s| !s.is_empty())
223        .or_else(|| {
224            entry
225                .get("state")
226                .and_then(Value::as_str)
227                .filter(|s| !s.is_empty())
228        })
229        .unwrap_or("")
230        .to_ascii_uppercase();
231    if FAILURE_STATES.contains(&raw.as_str()) {
232        return EntryState::Failure;
233    }
234    if SUCCESS_STATES.contains(&raw.as_str()) {
235        return EntryState::Success;
236    }
237    EntryState::Pending
238}
239
240/// Reduces a PR's rollup contexts to one verdict: any failing check dominates
241/// (`failure`); else any still-running one (`pending`); else `success`. An empty
242/// rollup means no checks (`none`) — the only way `none` arises, since every entry
243/// classifies as one of the three.
244fn rollup_check_state(contexts: &[Value]) -> PrCheckState {
245    if contexts.is_empty() {
246        return PrCheckState::None;
247    }
248    let mut saw_pending = false;
249    for entry in contexts {
250        match check_entry_state(entry) {
251            EntryState::Failure => return PrCheckState::Failure,
252            EntryState::Pending => saw_pending = true,
253            EntryState::Success => {}
254        }
255    }
256    if saw_pending {
257        return PrCheckState::Pending;
258    }
259    // Every check *that exists* has passed — but more may still be coming, so this
260    // is not yet a pass. See [`suite_still_running`].
261    if contexts.iter().any(suite_still_running) {
262        return PrCheckState::Pending;
263    }
264    // Non-empty, nothing failing, nothing running, every suite terminal ⇒ passed.
265    // (The TypeScript tracked a `sawSuccess` flag here; it could never be false at
266    // this point, so the branch it guarded was dead.)
267    PrCheckState::Success
268}
269
270/// Whether the check **suite** backing this entry is still running.
271///
272/// This is what catches the `needs:`-gate false green. GitHub does not create a
273/// gated job's check run until its dependency completes, so in the window between
274/// the gate passing and the fan-out appearing, every check run that *exists* is
275/// green and the rollup reduces to `success` — GitHub's own aggregate `state` says
276/// `SUCCESS` too. Only the suite knows more jobs are coming.
277///
278/// Reading the suite off a **`CheckRun` in the rollup** (rather than querying
279/// `checkSuites` on the commit) is what makes this safe as well as free. A suite
280/// only appears here by way of a check run it owns, so a suite with **zero** runs —
281/// e.g. codecov leaves one `QUEUED` on every PR, observed still queued after 3.7
282/// days — is never seen and cannot pin a badge yellow forever. No age heuristic, no
283/// app denylist, and no third-level `checkRuns { totalCount }` connection (which
284/// would cost 11 points instead of 1).
285fn suite_still_running(entry: &Value) -> bool {
286    entry
287        .get("checkSuite")
288        .and_then(|suite| suite.get("status"))
289        .and_then(Value::as_str)
290        .is_some_and(|status| !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED"))
291}
292
293/// The GraphQL fragment resolving one branch: its head OID, the rollup contexts
294/// the verdict is reduced from, and the open PR that heads it.
295///
296/// `associatedPullRequests` is read off the **`Ref`**, filtered to `OPEN`, not off
297/// the `Commit`. On a `Commit` it returns whatever PR introduced that commit — on
298/// `main` that is the last *merged* PR, from an unrelated branch, which would paint
299/// a false badge. On a `Ref` it matches on **head**, reproducing the extension's
300/// "first open PR whose `headRefName` is this branch" rule (verified: `main` is the
301/// base of many open PRs and correctly resolves to nothing).
302fn branch_fragment(alias: &str, branch: &str) -> String {
303    // JSON string escaping is valid GraphQL string escaping, so this is safe for
304    // any branch name git permits.
305    let qualified = Value::String(format!("refs/heads/{branch}"));
306    format!(
307        r"{alias}: ref(qualifiedName:{qualified}){{
308      target{{ ...on Commit{{ oid
309        statusCheckRollup{{ contexts(first:100){{ nodes{{
310          __typename
311          ...on CheckRun{{ status conclusion checkSuite{{ status }} }}
312          ...on StatusContext{{ state }}
313        }} }} }}
314      }} }}
315      associatedPullRequests(first:1, states:OPEN){{ nodes{{ number isDraft url }} }}
316    }}"
317    )
318}
319
320/// Maps a query's `(repo alias index, branch alias index)` back to the target it
321/// was built for, so the reply can be read without re-deriving the aliasing.
322type QueryIndex = HashMap<(usize, usize), PrTarget>;
323
324/// Builds the single aliased query for every target, plus the alias→target index
325/// needed to read the reply back. Targets are grouped by repo so each repo appears
326/// once. Returns `None` when there is nothing to ask.
327fn build_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
328    if targets.is_empty() {
329        return None;
330    }
331    // BTreeMap so aliases are assigned deterministically — the query text is then
332    // stable for a stable target set, which keeps it testable.
333    let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
334    for t in targets {
335        by_repo
336            .entry((t.owner.as_str(), t.name.as_str()))
337            .or_default()
338            .push(t);
339    }
340    let mut index = HashMap::new();
341    let mut repos = Vec::new();
342    for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
343        let mut frags = Vec::new();
344        for (bi, target) in branches.iter().enumerate() {
345            frags.push(branch_fragment(&format!("b{bi}"), &target.branch));
346            index.insert((ri, bi), (*target).clone());
347        }
348        let owner = Value::String((*owner).to_string());
349        let name = Value::String((*name).to_string());
350        repos.push(format!(
351            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
352            frags.join("\n")
353        ));
354    }
355    // Fold the graphql budget into every poll (#1389, fix 8): `rateLimit` is a
356    // meta-field GitHub answers for free (it does not add to the query's own
357    // `cost`), so each PR poll doubles as a live budget reading — and its `cost`
358    // reveals the actual per-call point price, verifying the "1 point" assumption at
359    // scale instead of taking it on faith.
360    Some((
361        format!(
362            "query{{\nrateLimit{{ limit cost remaining used resetAt }}\n{}\n}}",
363            repos.join("\n")
364        ),
365        index,
366    ))
367}
368
369/// The `rateLimit` block folded into every PR-poll reply (#1389, fix 8).
370///
371/// Carries the query's own point `cost` plus the live graphql budget. Every field
372/// is what GitHub's `rateLimit` object reports; `reset` is `resetAt` (ISO-8601)
373/// parsed to a Unix epoch so it drops straight into a
374/// [`RateLimitResource`](crate::github_rate_limit::RateLimitResource).
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub struct QueryRateLimit {
377    /// The point cost GitHub charged for *this* query.
378    pub cost: u64,
379    /// Points spent in the current graphql window.
380    pub used: u64,
381    /// The window ceiling (5,000/hour today).
382    pub limit: u64,
383    /// Points remaining in the window.
384    pub remaining: u64,
385    /// When the window resets, as a Unix epoch (seconds); `0` if `resetAt` was
386    /// absent or unparseable.
387    pub reset: i64,
388}
389
390/// Reads the `rateLimit` block from a poll reply, or `None` when it is absent
391/// (an older query shape, or a reply that carried none). Best-effort: a partial
392/// block missing any of `cost`/`used`/`limit`/`remaining` yields `None` rather
393/// than a half-populated reading.
394fn parse_rate_limit(body: &Value) -> Option<QueryRateLimit> {
395    let rl = body.get("data")?.get("rateLimit")?;
396    let reset = rl
397        .get("resetAt")
398        .and_then(Value::as_str)
399        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
400        .map_or(0, |dt| dt.timestamp());
401    Some(QueryRateLimit {
402        cost: rl.get("cost")?.as_u64()?,
403        used: rl.get("used")?.as_u64()?,
404        limit: rl.get("limit")?.as_u64()?,
405        remaining: rl.get("remaining")?.as_u64()?,
406        reset,
407    })
408}
409
410/// Reads one resolved `ref` node into a badge. `None` for a node without a
411/// readable open PR; [`resolution_from_ref`] decides whether that means "no PR"
412/// (an explicit negative) or "malformed" (unresolved) before delegating here.
413fn badge_from_ref(node: &Value) -> Option<PrBadge> {
414    let pr = node
415        .get("associatedPullRequests")?
416        .get("nodes")?
417        .as_array()?
418        .first()?;
419    let contexts = node
420        .get("target")
421        .and_then(|t| t.get("statusCheckRollup"))
422        .and_then(|r| r.get("contexts"))
423        .and_then(|c| c.get("nodes"))
424        .and_then(Value::as_array)
425        .cloned()
426        .unwrap_or_default();
427    Some(PrBadge {
428        number: pr.get("number").and_then(Value::as_u64)?,
429        is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
430        checks: rollup_check_state(&contexts),
431        url: pr
432            .get("url")
433            .and_then(Value::as_str)
434            .unwrap_or_default()
435            .to_string(),
436        // The commit this verdict is about — the remote branch head the rollup was
437        // read from, not the PR's own `headRefOid` (the same commit, one fewer field
438        // to ask for).
439        head_oid: node
440            .get("target")
441            .and_then(|t| t.get("oid"))
442            .and_then(Value::as_str)
443            .unwrap_or_default()
444            .to_string(),
445    })
446}
447
448/// Reads one **non-null** `ref` node into a resolution: [`PrResolution::NoPr`]
449/// when the PR list is present and empty, a badge for an open PR, and `None` —
450/// *unresolved*, never a negative — for a malformed node. A false negative would
451/// silence the client fallback for a branch that may well have a PR, so anything
452/// we cannot positively read stays unresolved (#1370).
453fn resolution_from_ref(node: &Value) -> Option<PrResolution> {
454    let prs = node
455        .get("associatedPullRequests")?
456        .get("nodes")?
457        .as_array()?;
458    if prs.is_empty() {
459        return Some(PrResolution::NoPr);
460    }
461    badge_from_ref(node).map(PrResolution::Pr)
462}
463
464/// Reads the GraphQL reply back into resolutions, keyed by target.
465///
466/// Tri-state per target (#1370): an open PR yields its badge, a checked branch
467/// with none — including one never pushed, whose aliased `ref` comes back null —
468/// yields [`PrResolution::NoPr`], and a target whose part of the reply is missing
469/// or malformed is simply **absent** (unresolved). Best-effort per branch: one
470/// bad node never sinks the whole poll, and never mints a negative.
471fn parse_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, PrResolution> {
472    let mut out = HashMap::new();
473    let Some(data) = body.get("data") else {
474        return out;
475    };
476    for ((ri, bi), target) in index {
477        // A repo alias absent or null (a repo-level failure) is unresolved — only
478        // an answer *about the ref* may mint a negative.
479        let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
480            continue;
481        };
482        let Some(node) = repo.get(format!("b{bi}")) else {
483            continue;
484        };
485        // A null ref is a branch that does not exist on the remote (never
486        // pushed): checked, and definitively PR-less.
487        if node.is_null() {
488            out.insert(target.clone(), PrResolution::NoPr);
489            continue;
490        }
491        if let Some(resolution) = resolution_from_ref(node) {
492            out.insert(target.clone(), resolution);
493        }
494    }
495    out
496}
497
498/// Resolves `gh`, preferring [`GH_BIN_ENV`], then the first existing well-known
499/// absolute path, then bare `gh` on `PATH`.
500///
501/// Callers should do this **once** (the poller resolves it at spawn) and pass the
502/// result to [`resolve_with`], rather than re-reading the environment per poll.
503#[must_use]
504pub fn resolve_gh_binary() -> PathBuf {
505    resolve_gh_binary_from(std::env::var_os(GH_BIN_ENV), GH_BINARY_CANDIDATES)
506}
507
508/// The testable core of [`resolve_gh_binary`]. Split so the probe order can be
509/// unit-tested without mutating the process environment.
510fn resolve_gh_binary_from(
511    env_override: Option<std::ffi::OsString>,
512    candidates: &[&str],
513) -> PathBuf {
514    if let Some(path) = env_override.filter(|p| !p.is_empty()) {
515        return PathBuf::from(path);
516    }
517    for candidate in candidates {
518        let path = Path::new(candidate);
519        if path.exists() {
520            return path.to_path_buf();
521        }
522    }
523    PathBuf::from("gh")
524}
525
526/// Runs one `gh api graphql` call against `bin`. **Blocking** — callers must be on
527/// a blocking thread, never an async worker.
528fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
529    let query_arg = format!("query={query}");
530    let output = crate::github_metrics::run_gh(
531        bin,
532        ["api", "graphql", "-f", query_arg.as_str()],
533        "api graphql",
534        None,
535    )
536    .with_context(|| {
537        format!(
538            "failed to run {} (is the GitHub CLI installed?)",
539            bin.display()
540        )
541    })?;
542    if !output.status.success() {
543        let stderr = String::from_utf8_lossy(&output.stderr);
544        bail!("gh api graphql failed: {}", stderr.trim());
545    }
546    serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
547}
548
549/// Resolves every target in **one** `gh api graphql` call, using the `gh` at
550/// `bin`.
551///
552/// The binary is a parameter rather than resolved here so callers read the
553/// environment **once** (the poller does it at spawn, like its interval) and so
554/// tests inject a stub without mutating the process environment — two parallel
555/// tests pointing one global env var at different fakes race, and the project is
556/// migrating away from test env locks toward injection (#1030).
557///
558/// **Blocking** — run on a blocking thread. Every target that was successfully
559/// checked appears in the map: [`PrResolution::Pr`] for an open PR,
560/// [`PrResolution::NoPr`] for a branch — pushed or not — with none (#1370). A
561/// target is *absent* only when its part of the reply was missing or malformed,
562/// and a failed call (`Err`, including a GraphQL 200 carrying `errors`) yields no
563/// map at all — so a failed poll can never manufacture negatives.
564pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrResolution>> {
565    resolve_inner(bin, targets).map(|(resolutions, _budget)| resolutions)
566}
567
568/// [`resolve_with`] that **also** returns the poll's folded-in graphql budget
569/// reading (#1389, fix 8).
570///
571/// For the daemon poller to feed the shared
572/// [`RateLimitCache`](crate::github_rate_limit::RateLimitCache). The budget is
573/// `None` when there were no targets (no query ran) or the reply carried no
574/// `rateLimit` block; the resolutions are exactly what [`resolve_with`] returns.
575pub fn resolve_with_budget(
576    bin: &Path,
577    targets: &[PrTarget],
578) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
579    resolve_inner(bin, targets)
580}
581
582/// The shared body of [`resolve_with`] / [`resolve_with_budget`]: one `gh api
583/// graphql` call, parsed into resolutions **and** the optional budget reading.
584fn resolve_inner(
585    bin: &Path,
586    targets: &[PrTarget],
587) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
588    let Some((query, index)) = build_query(targets) else {
589        return Ok((HashMap::new(), None));
590    };
591    let body = run_gh_graphql(bin, &query)?;
592    // A GraphQL 200 can still carry errors; surface them rather than silently
593    // reporting "no badges", which would look identical to "no PRs".
594    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
595        if !errors.is_empty() {
596            bail!("gh api graphql returned errors: {errors:?}");
597        }
598    }
599    Ok((parse_response(&body, &index), parse_rate_limit(&body)))
600}
601
602/// The poller-written, snapshot-read resolution cache.
603///
604/// A plain `std::Mutex` map: writes come from the poll loop, reads from the tree
605/// snapshot build. The lock is never held across an `.await` — every method takes
606/// it, finishes, and drops it.
607#[derive(Debug, Default)]
608pub struct PrStatusCache {
609    resolutions: Mutex<HashMap<PrTarget, PrResolution>>,
610}
611
612impl PrStatusCache {
613    /// An empty cache. Until the first poll lands, every lookup misses and the
614    /// tree simply renders no badge — and no negative either (#1370).
615    #[must_use]
616    pub fn new() -> Self {
617        Self::default()
618    }
619
620    /// The resolution for one (repo, branch): a badge, an explicit no-PR, or
621    /// `None` when never successfully checked.
622    #[must_use]
623    pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrResolution> {
624        let key = PrTarget {
625            owner: owner.to_string(),
626            name: name.to_string(),
627            branch: branch.to_string(),
628        };
629        self.lock().get(&key).cloned()
630    }
631
632    /// Replaces the cache wholesale, returning whether anything actually changed.
633    ///
634    /// The bool is load-bearing: the caller bumps the registry's change-notify only
635    /// when it is `true`. Bumping unconditionally would defeat the server's
636    /// diff-and-drop and re-push an identical snapshot to every window on every
637    /// poll — the cost this whole design exists to avoid. A first round of
638    /// negatives *is* a change — the one push that delivers them — after which
639    /// identical polls stay silent.
640    pub fn replace(&self, next: HashMap<PrTarget, PrResolution>) -> bool {
641        let mut guard = self.lock();
642        if *guard == next {
643            return false;
644        }
645        *guard = next;
646        true
647    }
648
649    /// Drops every cached resolution whose target is absent from `keep`, returning
650    /// whether anything was removed.
651    ///
652    /// Lets the poller prune the verdicts of worktrees that closed (or repos whose
653    /// lease lapsed) **without** spending a `gh` call to re-resolve the survivors
654    /// (#1389, fix 1) — the wholesale [`replace`](Self::replace) only prunes as a
655    /// side effect of a fetch, which a pure removal must never trigger. No caller
656    /// bumps on the return today (a removed target's row is already gone from the
657    /// tree, so nothing re-renders), but the bool is reported for symmetry with
658    /// `replace` and to keep the prune observable in tests.
659    pub fn retain_targets(&self, keep: &HashSet<PrTarget>) -> bool {
660        let mut guard = self.lock();
661        let before = guard.len();
662        guard.retain(|target, _| keep.contains(target));
663        guard.len() != before
664    }
665
666    /// Every cached (target, resolution) pair, cloned out for persistence (#1389,
667    /// fix 4). Ordering is unspecified (a `HashMap`); the caller sorts if it needs
668    /// a stable on-disk form.
669    #[must_use]
670    pub fn entries(&self) -> Vec<(PrTarget, PrResolution)> {
671        self.lock()
672            .iter()
673            .map(|(t, r)| (t.clone(), r.clone()))
674            .collect()
675    }
676
677    /// Seeds the cache from a persisted set, **without** bumping any change-notify.
678    ///
679    /// Called once at daemon startup (before any window subscribes) so restored
680    /// badges render on the first tree snapshot rather than after the first poll
681    /// (#1389, fix 4) — the [`crate::pr_status`] analogue of
682    /// `WorktreesRegistry::seed_polling`. Existing entries with the same target are
683    /// overwritten; the cache is empty at that point, so in practice this inserts.
684    pub fn seed(&self, entries: impl IntoIterator<Item = (PrTarget, PrResolution)>) {
685        let mut guard = self.lock();
686        for (target, resolution) in entries {
687            guard.insert(target, resolution);
688        }
689    }
690
691    /// Whether any cached badge is still pending — the poller's cadence signal. A
692    /// negative is terminal and never holds the fast cadence.
693    #[must_use]
694    pub fn any_pending(&self) -> bool {
695        self.lock()
696            .values()
697            .any(|r| matches!(r, PrResolution::Pr(b) if b.checks == PrCheckState::Pending))
698    }
699
700    /// Poison-tolerant lock: a panicking holder must not wedge the badge cache,
701    /// which is best-effort decoration.
702    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrResolution>> {
703        self.resolutions
704            .lock()
705            .unwrap_or_else(PoisonError::into_inner)
706    }
707}
708
709#[cfg(test)]
710#[allow(clippy::unwrap_used, clippy::expect_used)]
711mod tests {
712    use super::*;
713    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
714    use serde_json::json;
715    use std::sync::MutexGuard;
716
717    fn target(branch: &str) -> PrTarget {
718        PrTarget {
719            owner: "rust-works".into(),
720            name: "omni-dev".into(),
721            branch: branch.into(),
722        }
723    }
724
725    // --- Reducer: ported verbatim from the extension's github.test.ts so the
726    //     daemon-side move is behaviour-preserving (#1337 PR 2). ---
727
728    #[test]
729    fn rollup_is_none_for_an_empty_rollup() {
730        assert_eq!(rollup_check_state(&[]), PrCheckState::None);
731    }
732
733    #[test]
734    fn rollup_reads_completed_check_run_conclusions() {
735        for (conclusion, want) in [
736            ("SUCCESS", PrCheckState::Success),
737            ("NEUTRAL", PrCheckState::Success),
738            ("SKIPPED", PrCheckState::Success),
739            ("FAILURE", PrCheckState::Failure),
740            ("CANCELLED", PrCheckState::Failure),
741            ("TIMED_OUT", PrCheckState::Failure),
742            ("ACTION_REQUIRED", PrCheckState::Failure),
743            ("STARTUP_FAILURE", PrCheckState::Failure),
744            ("STALE", PrCheckState::Failure),
745        ] {
746            let entry =
747                json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
748            assert_eq!(
749                rollup_check_state(&[entry]),
750                want,
751                "conclusion {conclusion} should be {want:?}"
752            );
753        }
754    }
755
756    #[test]
757    fn rollup_treats_an_incomplete_check_run_as_pending() {
758        // The conclusion is null while running; the status decides.
759        for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
760            let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
761            assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
762        }
763    }
764
765    #[test]
766    fn rollup_reads_status_context_states() {
767        // A legacy StatusContext has no `status`, so `state` decides.
768        for (state, want) in [
769            ("SUCCESS", PrCheckState::Success),
770            ("FAILURE", PrCheckState::Failure),
771            ("ERROR", PrCheckState::Failure),
772            ("PENDING", PrCheckState::Pending),
773            ("EXPECTED", PrCheckState::Pending),
774        ] {
775            let entry = json!({"__typename":"StatusContext","state":state});
776            assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
777        }
778    }
779
780    #[test]
781    fn rollup_never_reads_an_unknown_value_as_a_pass() {
782        // The load-bearing rule: anything unrecognised is pending, never success.
783        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
784        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
785        // A completed-but-unset conclusion likewise.
786        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
787        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
788    }
789
790    #[test]
791    fn rollup_precedence_is_failure_then_pending_then_success() {
792        let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
793        let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
794        let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
795        // Failure dominates everything, in either order.
796        assert_eq!(
797            rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
798            PrCheckState::Failure
799        );
800        assert_eq!(
801            rollup_check_state(&[bad, ok.clone()]),
802            PrCheckState::Failure
803        );
804        // Pending beats success.
805        assert_eq!(
806            rollup_check_state(&[ok.clone(), run]),
807            PrCheckState::Pending
808        );
809        assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
810    }
811
812    // --- Suite awareness: the `needs:`-gate false green ---
813
814    fn run(conclusion: &str, suite: Option<&str>) -> Value {
815        let mut e = json!({
816            "__typename": "CheckRun",
817            "status": "COMPLETED",
818            "conclusion": conclusion,
819        });
820        if let Some(s) = suite {
821            e["checkSuite"] = json!({ "status": s });
822        }
823        e
824    }
825
826    #[test]
827    fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
828        // The exact shape observed on PRs #1294/#1329: every check run that exists
829        // is green — GitHub's own rollup `state` reads SUCCESS — but the CI suite is
830        // still spawning the `needs: gate` fan-out. Reporting success here is the
831        // false ✓ this issue is about.
832        let contexts = vec![
833            run("SUCCESS", Some("IN_PROGRESS")),
834            run("SUCCESS", Some("IN_PROGRESS")),
835        ];
836        assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
837        // QUEUED counts too — the suite exists but has not started its fan-out.
838        assert_eq!(
839            rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
840            PrCheckState::Pending
841        );
842    }
843
844    #[test]
845    fn rollup_is_success_once_every_backing_suite_is_terminal() {
846        let contexts = vec![
847            run("SUCCESS", Some("COMPLETED")),
848            run("SKIPPED", Some("COMPLETED")),
849        ];
850        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
851    }
852
853    #[test]
854    fn rollup_ignores_a_zero_run_zombie_suite() {
855        // codecov leaves a QUEUED suite with **zero** check runs on every PR — one
856        // was still queued after 3.7 days. A rule keyed on "any non-terminal suite"
857        // would pin such a PR yellow forever. Keying on suites reachable *through a
858        // check run* excludes it structurally: with no runs, it never appears in the
859        // rollup at all. So a rollup whose runs all carry terminal suites is green,
860        // even though a zombie suite exists on the commit.
861        let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
862        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
863    }
864
865    #[test]
866    fn rollup_tolerates_entries_without_suite_information() {
867        // A legacy StatusContext has no `checkSuite`, and neither does a CheckRun if
868        // the field is ever absent. Missing suite info must not imply "still
869        // running", or every StatusContext-only repo pins yellow.
870        assert_eq!(
871            rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
872            PrCheckState::Success
873        );
874        assert_eq!(
875            rollup_check_state(&[run("SUCCESS", None)]),
876            PrCheckState::Success
877        );
878        // An empty suite status is not a running suite either.
879        assert_eq!(
880            rollup_check_state(&[run("SUCCESS", Some(""))]),
881            PrCheckState::Success
882        );
883    }
884
885    #[test]
886    fn rollup_failure_still_dominates_a_running_suite() {
887        // A red check is red regardless of what else is still spawning.
888        let contexts = vec![
889            run("FAILURE", Some("IN_PROGRESS")),
890            run("SUCCESS", Some("IN_PROGRESS")),
891        ];
892        assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
893    }
894
895    #[test]
896    fn build_query_asks_for_the_backing_suite_status() {
897        let (query, _) = build_query(&[target("main")]).unwrap();
898        assert!(query.contains("checkSuite{ status }"), "{query}");
899    }
900
901    #[test]
902    fn build_query_folds_in_the_rate_limit_block() {
903        // #1389, fix 8: every poll carries a free budget reading.
904        let (query, _) = build_query(&[target("main")]).unwrap();
905        assert!(
906            query.contains("rateLimit{ limit cost remaining used resetAt }"),
907            "{query}"
908        );
909    }
910
911    #[test]
912    fn parse_rate_limit_reads_the_folded_budget_block() {
913        let body = json!({"data":{"rateLimit":
914            {"limit":5000,"cost":1,"remaining":4970,"used":30,"resetAt":"2026-07-21T16:00:00Z"}}});
915        let rl = parse_rate_limit(&body).expect("a complete block should parse");
916        assert_eq!(rl.cost, 1);
917        assert_eq!(rl.used, 30);
918        assert_eq!(rl.limit, 5000);
919        assert_eq!(rl.remaining, 4970);
920        assert!(rl.reset > 0, "resetAt should parse to an epoch");
921        // An older query shape (no block) reads as `None`, not a zeroed budget.
922        assert!(parse_rate_limit(&json!({"data":{"r0":{}}})).is_none());
923        // A partial block (missing `used`) is `None`, never half-populated.
924        assert!(parse_rate_limit(
925            &json!({"data":{"rateLimit":{"limit":5000,"cost":1,"remaining":4970}}})
926        )
927        .is_none());
928    }
929
930    // --- Query shape ---
931
932    #[test]
933    fn build_query_is_none_without_targets() {
934        assert!(build_query(&[]).is_none());
935    }
936
937    #[test]
938    fn build_query_groups_branches_under_one_repo_alias() {
939        let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
940        // One repo → one `repository(...)` block, two `ref(...)` aliases.
941        assert_eq!(query.matches("repository(owner:").count(), 1);
942        assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
943        assert!(query.contains(r#""refs/heads/main""#), "{query}");
944        assert!(query.contains(r#""refs/heads/feature""#), "{query}");
945        assert_eq!(index.len(), 2);
946    }
947
948    #[test]
949    fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
950        // Guards the semantic trap: `Commit.associatedPullRequests` returns the PR
951        // that *introduced* the commit — on `main`, the last merged PR from an
952        // unrelated branch — which would paint a false badge. It must be read off
953        // the Ref, filtered to OPEN, which matches on head.
954        let (query, _) = build_query(&[target("main")]).unwrap();
955        assert!(
956            query.contains("associatedPullRequests(first:1, states:OPEN)"),
957            "{query}"
958        );
959        // The PR lookup must sit outside the `target{...on Commit{...}}` block.
960        let commit_block = query.find("...on Commit").unwrap();
961        let pr_lookup = query.find("associatedPullRequests").unwrap();
962        assert!(
963            pr_lookup > commit_block,
964            "PR lookup must be on the Ref, after the Commit block"
965        );
966    }
967
968    #[test]
969    fn build_query_separates_distinct_repos() {
970        let a = PrTarget {
971            owner: "o1".into(),
972            name: "r1".into(),
973            branch: "main".into(),
974        };
975        let b = PrTarget {
976            owner: "o2".into(),
977            name: "r2".into(),
978            branch: "main".into(),
979        };
980        let (query, index) = build_query(&[a, b]).unwrap();
981        assert_eq!(query.matches("repository(owner:").count(), 2);
982        assert_eq!(index.len(), 2);
983    }
984
985    #[test]
986    fn build_query_escapes_branch_names() {
987        // Defensive: JSON escaping keeps a quote in a ref name from breaking out of
988        // the GraphQL string literal.
989        let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
990        assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
991    }
992
993    // --- Reply parsing ---
994
995    #[test]
996    fn parse_response_reads_badges_and_resolves_absent_refs_as_negatives() {
997        let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
998        let (_, index) = build_query(&targets).unwrap();
999        // Aliases are assigned in BTreeMap order of (owner,name) then input order.
1000        let body = json!({"data":{"r0":{
1001            "b0": {
1002                "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
1003                    {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1004                ]}}},
1005                "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
1006            },
1007            // An unpushed branch resolves to a null ref — checked, and PR-less.
1008            "b1": null,
1009            // Pushed, but no open PR heads it.
1010            "b2": {
1011                "target": {"oid":"def","statusCheckRollup":null},
1012                "associatedPullRequests":{"nodes":[]}
1013            }
1014        }}});
1015        let out = parse_response(&body, &index);
1016        assert_eq!(out.len(), 3, "{out:?}");
1017        let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1018            panic!("expected a badge for feature: {out:?}");
1019        };
1020        assert_eq!(badge.number, 65);
1021        assert!(badge.is_draft);
1022        assert_eq!(badge.checks, PrCheckState::Success);
1023        assert_eq!(badge.url, "u");
1024        assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1025        assert_eq!(out.get(&target("no-pr")), Some(&PrResolution::NoPr));
1026    }
1027
1028    #[test]
1029    fn parse_response_reports_a_pushed_branch_with_no_pr_as_a_negative() {
1030        let (_, index) = build_query(&[target("quiet")]).unwrap();
1031        let body = json!({"data":{"r0":{"b0":{
1032            "target": {"oid":"abc","statusCheckRollup":null},
1033            "associatedPullRequests":{"nodes":[]}
1034        }}}});
1035        let out = parse_response(&body, &index);
1036        assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1037    }
1038
1039    #[test]
1040    fn parse_response_reports_an_unpushed_branch_as_a_negative() {
1041        let (_, index) = build_query(&[target("local-only")]).unwrap();
1042        let body = json!({"data":{"r0":{"b0":null}}});
1043        let out = parse_response(&body, &index);
1044        assert_eq!(out.get(&target("local-only")), Some(&PrResolution::NoPr));
1045    }
1046
1047    #[test]
1048    fn parse_response_leaves_a_missing_alias_unresolved_rather_than_negative() {
1049        // Only an answer about the ref may mint a negative: a reply missing the
1050        // ref alias, or with a null repo alias (a repo-level failure), says
1051        // nothing about the branch — a false NoPr would silence the client
1052        // fallback for a branch that may have a PR.
1053        let (_, index) = build_query(&[target("x")]).unwrap();
1054        assert!(parse_response(&json!({"data":{"r0":{}}}), &index).is_empty());
1055        assert!(parse_response(&json!({"data":{"r0":null}}), &index).is_empty());
1056    }
1057
1058    #[test]
1059    fn parse_response_leaves_a_malformed_pr_node_unresolved_rather_than_negative() {
1060        let (_, index) = build_query(&[target("x")]).unwrap();
1061        // The PR list is non-empty, so this is not "no PR" — but the node has no
1062        // readable number, so it is not a badge either. It must stay unresolved.
1063        let body = json!({"data":{"r0":{"b0":{
1064            "target": {"oid":"abc","statusCheckRollup":null},
1065            "associatedPullRequests":{"nodes":[{"isDraft":false,"url":"u"}]}
1066        }}}});
1067        assert!(parse_response(&body, &index).is_empty());
1068    }
1069
1070    #[test]
1071    fn parse_response_reads_a_pr_with_no_checks_as_none() {
1072        let targets = vec![target("feature")];
1073        let (_, index) = build_query(&targets).unwrap();
1074        let body = json!({"data":{"r0":{"b0":{
1075            "target": {"oid":"abc","statusCheckRollup":null},
1076            "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
1077        }}}});
1078        let out = parse_response(&body, &index);
1079        // A PR with no checks is still a PR — PrCheckState::None, never NoPr.
1080        let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1081            panic!("expected a badge: {out:?}");
1082        };
1083        assert_eq!(badge.checks, PrCheckState::None);
1084    }
1085
1086    #[test]
1087    fn parse_response_tolerates_a_missing_data_block() {
1088        // A reply without `data` resolves nothing — and mints no negatives.
1089        let (_, index) = build_query(&[target("x")]).unwrap();
1090        assert!(parse_response(&json!({}), &index).is_empty());
1091    }
1092
1093    // --- Wire shape ---
1094
1095    #[test]
1096    fn badge_serializes_is_draft_as_camel_case() {
1097        // The extension's PrBadge inherited `isDraft` from gh's JSON. Serializing it
1098        // as `is_draft` would silently drop the draft marker on every row.
1099        let badge = PrBadge {
1100            number: 65,
1101            is_draft: true,
1102            checks: PrCheckState::Pending,
1103            url: "u".into(),
1104            head_oid: String::new(),
1105        };
1106        let v = serde_json::to_value(&badge).unwrap();
1107        assert_eq!(v["isDraft"], json!(true));
1108        assert_eq!(v["checks"], json!("pending"));
1109        assert!(v.get("is_draft").is_none(), "{v}");
1110    }
1111
1112    #[test]
1113    fn check_state_serializes_lowercase() {
1114        for (state, want) in [
1115            (PrCheckState::Success, "success"),
1116            (PrCheckState::Failure, "failure"),
1117            (PrCheckState::Pending, "pending"),
1118            (PrCheckState::None, "none"),
1119        ] {
1120            assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
1121        }
1122    }
1123
1124    // --- Binary resolution ---
1125
1126    #[test]
1127    fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
1128        assert_eq!(
1129            resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
1130            PathBuf::from("/custom/gh")
1131        );
1132        let existing = tempfile::NamedTempFile::new().unwrap();
1133        let existing_path = existing.path().to_str().unwrap();
1134        assert_eq!(
1135            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
1136            PathBuf::from(existing_path)
1137        );
1138        assert_eq!(
1139            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
1140            PathBuf::from("gh")
1141        );
1142        // An empty override falls through rather than resolving to "".
1143        assert_eq!(
1144            resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
1145            PathBuf::from("gh")
1146        );
1147        // The real-env wrapper resolves without panicking.
1148        let _ = resolve_gh_binary();
1149    }
1150
1151    // --- resolve_with: the degradation contract ---
1152    //
1153    // Badges are decoration: a missing, unauthenticated, or failing `gh` must
1154    // surface an error to the poller (which backs off and keeps the last good
1155    // badges) and never panic or hang. These cover the paths that decide that.
1156
1157    /// Writes an executable stub standing in for `gh`, printing `stdout` and
1158    /// exiting `code`.
1159    ///
1160    /// Returns the shim serialisation lock alongside the path: the caller
1161    /// **must** hold the guard until it has finished exec'ing the stub, so
1162    /// concurrent shim subprocesses stay bounded. That lock does **not** close
1163    /// the `ETXTBSY` ("Text file busy") race — writing an executable and then
1164    /// `execve`ing it races every other thread that forks, which never takes
1165    /// this lock — so the caller also runs the exec through [`retry_on_etxtbsy`]
1166    /// (#642, #1344, #1348).
1167    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1168        let guard = shim_lock();
1169        let path = dir.join("fake-gh");
1170        write_exec_script(
1171            &path,
1172            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
1173        );
1174        (path, guard)
1175    }
1176
1177    #[test]
1178    fn resolve_with_asks_nothing_for_no_targets() {
1179        // No branches to resolve → no subprocess at all. The binary is deliberately
1180        // bogus: if this spawned anything it would error instead of returning empty.
1181        let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1182        assert!(out.is_empty());
1183    }
1184
1185    #[test]
1186    fn resolve_with_errors_when_gh_is_missing() {
1187        // The common real case: `gh` not installed, or absent from launchd's
1188        // minimal PATH.
1189        let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
1190        let msg = format!("{err:#}");
1191        assert!(msg.contains("failed to run"), "{msg}");
1192        assert!(msg.contains("GitHub CLI"), "{msg}");
1193    }
1194
1195    #[test]
1196    fn resolve_with_errors_on_a_nonzero_exit() {
1197        // e.g. `gh auth login` never run: gh exits non-zero and explains on stderr.
1198        let dir = tempfile::tempdir().unwrap();
1199        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1200        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1201        assert!(
1202            format!("{err:#}").contains("gh api graphql failed"),
1203            "{err:#}"
1204        );
1205    }
1206
1207    #[test]
1208    fn resolve_with_errors_on_unparseable_output() {
1209        let dir = tempfile::tempdir().unwrap();
1210        let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
1211        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1212        assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
1213    }
1214
1215    #[test]
1216    fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
1217        // A GraphQL 200 can still carry errors (a bad field, a rate limit). Reading
1218        // that as an empty result would be indistinguishable from "no open PRs" and
1219        // would silently blank every badge, so it must be an error. Doubly
1220        // load-bearing since #1370: the hard Err is what guarantees a failed poll
1221        // can never manufacture NoPr negatives.
1222        let dir = tempfile::tempdir().unwrap();
1223        let (bin, _shim) = fake_gh(
1224            dir.path(),
1225            r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
1226            0,
1227        );
1228        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1229        let msg = format!("{err:#}");
1230        assert!(msg.contains("returned errors"), "{msg}");
1231        assert!(msg.contains("rate limit"), "{msg}");
1232    }
1233
1234    #[test]
1235    fn resolve_with_ignores_an_empty_errors_array() {
1236        // Some responses carry `errors: []`; that is a success, not a failure.
1237        let dir = tempfile::tempdir().unwrap();
1238        let (bin, _shim) = fake_gh(
1239            dir.path(),
1240            r#"{"errors":[],"data":{"r0":{"b0":{
1241                "target":{"oid":"a","statusCheckRollup":null},
1242                "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
1243            0,
1244        );
1245        let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1246        let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1247            panic!("expected a badge: {out:?}");
1248        };
1249        assert_eq!(badge.number, 9);
1250    }
1251
1252    #[test]
1253    fn resolve_with_reads_a_real_reply_end_to_end() {
1254        let dir = tempfile::tempdir().unwrap();
1255        let (bin, _shim) = fake_gh(
1256            dir.path(),
1257            r#"{"data":{"r0":{"b0":{
1258                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1259                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
1260                ]}}},
1261                "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
1262            0,
1263        );
1264        let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1265        let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1266            panic!("expected a badge: {out:?}");
1267        };
1268        assert_eq!(badge.number, 42);
1269        assert!(badge.is_draft);
1270        assert_eq!(badge.checks, PrCheckState::Failure);
1271    }
1272
1273    #[test]
1274    fn resolve_with_reports_negatives_alongside_badges_end_to_end() {
1275        let dir = tempfile::tempdir().unwrap();
1276        let (bin, _shim) = fake_gh(
1277            dir.path(),
1278            r#"{"data":{"r0":{
1279                "b0":{
1280                    "target":{"oid":"a","statusCheckRollup":null},
1281                    "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}},
1282                "b1":null,
1283                "b2":{
1284                    "target":{"oid":"b","statusCheckRollup":null},
1285                    "associatedPullRequests":{"nodes":[]}}}}}"#,
1286            0,
1287        );
1288        let targets = vec![target("main"), target("unpushed"), target("quiet")];
1289        let out = retry_on_etxtbsy(|| resolve_with(&bin, &targets)).unwrap();
1290        assert_eq!(out.len(), 3, "{out:?}");
1291        assert!(
1292            matches!(out.get(&target("main")), Some(PrResolution::Pr(b)) if b.number == 7),
1293            "{out:?}"
1294        );
1295        assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1296        assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1297    }
1298
1299    // --- Cache ---
1300
1301    /// A pending badge wrapped as a resolution, for cache fixtures.
1302    fn pending_pr(number: u64) -> PrResolution {
1303        PrResolution::Pr(PrBadge {
1304            number,
1305            is_draft: false,
1306            checks: PrCheckState::Pending,
1307            url: "u".into(),
1308            head_oid: String::new(),
1309        })
1310    }
1311
1312    #[test]
1313    fn cache_replace_reports_whether_anything_changed() {
1314        let cache = PrStatusCache::new();
1315        let mut map = HashMap::new();
1316        map.insert(target("f"), pending_pr(1));
1317
1318        // First write is a change.
1319        assert!(cache.replace(map.clone()));
1320        // An identical write is not — this is what keeps the poller from re-pushing
1321        // an unchanged snapshot to every window on every tick.
1322        assert!(!cache.replace(map.clone()));
1323
1324        // A changed verdict is a change.
1325        let mut moved = map.clone();
1326        if let Some(PrResolution::Pr(badge)) = moved.get_mut(&target("f")) {
1327            badge.checks = PrCheckState::Success;
1328        }
1329        assert!(cache.replace(moved));
1330        // Emptying is a change.
1331        assert!(cache.replace(HashMap::new()));
1332        assert!(!cache.replace(HashMap::new()));
1333    }
1334
1335    #[test]
1336    fn cache_replace_counts_a_new_negative_as_a_change() {
1337        let cache = PrStatusCache::new();
1338        let mut negatives = HashMap::new();
1339        negatives.insert(target("f"), PrResolution::NoPr);
1340
1341        // The first round of negatives is a change — the one push that delivers
1342        // them to clients so their fallback goes quiet.
1343        assert!(cache.replace(negatives.clone()));
1344        // Re-polling the same answer is not.
1345        assert!(!cache.replace(negatives.clone()));
1346
1347        // A PR opening (negative → badge) and closing (badge → negative) are both
1348        // real transitions.
1349        let mut opened = HashMap::new();
1350        opened.insert(target("f"), pending_pr(1));
1351        assert!(cache.replace(opened));
1352        assert!(cache.replace(negatives));
1353    }
1354
1355    #[test]
1356    fn cache_any_pending_ignores_negative_resolutions() {
1357        // A negative is terminal: it must never hold the poller's fast cadence.
1358        let cache = PrStatusCache::new();
1359        let mut map = HashMap::new();
1360        map.insert(target("f"), PrResolution::NoPr);
1361        cache.replace(map);
1362        assert!(!cache.any_pending());
1363    }
1364
1365    #[test]
1366    fn cache_get_and_any_pending() {
1367        let cache = PrStatusCache::new();
1368        assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1369        assert!(!cache.any_pending());
1370
1371        let mut map = HashMap::new();
1372        map.insert(target("f"), pending_pr(1));
1373        cache.replace(map.clone());
1374        assert!(
1375            matches!(
1376                cache.get("rust-works", "omni-dev", "f"),
1377                Some(PrResolution::Pr(b)) if b.number == 1
1378            ),
1379            "expected the cached badge back"
1380        );
1381        assert!(cache.any_pending());
1382        // A miss on a branch we never resolved.
1383        assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1384
1385        if let Some(PrResolution::Pr(badge)) = map.get_mut(&target("f")) {
1386            badge.checks = PrCheckState::Success;
1387        }
1388        cache.replace(map);
1389        assert!(!cache.any_pending());
1390    }
1391
1392    #[test]
1393    fn cache_retain_targets_drops_only_the_vanished_entries() {
1394        // A pure removal (a worktree closed) must prune its verdict without a fetch
1395        // (#1389, fix 1), leaving the survivors untouched.
1396        let cache = PrStatusCache::new();
1397        let mut map = HashMap::new();
1398        map.insert(target("keep"), pending_pr(1));
1399        map.insert(target("drop"), pending_pr(2));
1400        cache.replace(map);
1401
1402        let keep: HashSet<PrTarget> = std::iter::once(target("keep")).collect();
1403        assert!(cache.retain_targets(&keep), "a target was removed");
1404        assert!(cache.get("rust-works", "omni-dev", "keep").is_some());
1405        assert!(cache.get("rust-works", "omni-dev", "drop").is_none());
1406        // Idempotent: nothing left to remove reports no change.
1407        assert!(!cache.retain_targets(&keep));
1408    }
1409
1410    #[test]
1411    fn cache_seed_then_entries_round_trips_without_a_bump() {
1412        // A warm start seeds restored verdicts so they render before the first poll
1413        // (#1389, fix 4); `entries` reads them back for the next persist.
1414        let cache = PrStatusCache::new();
1415        cache.seed([
1416            (target("f"), pending_pr(7)),
1417            (target("g"), PrResolution::NoPr),
1418        ]);
1419        assert!(matches!(
1420            cache.get("rust-works", "omni-dev", "f"),
1421            Some(PrResolution::Pr(b)) if b.number == 7
1422        ));
1423        assert!(matches!(
1424            cache.get("rust-works", "omni-dev", "g"),
1425            Some(PrResolution::NoPr)
1426        ));
1427        let mut entries = cache.entries();
1428        entries.sort_by(|a, b| a.0.cmp(&b.0));
1429        assert_eq!(entries.len(), 2);
1430        assert_eq!(entries[0].0, target("f"));
1431        assert_eq!(entries[1].0, target("g"));
1432    }
1433}