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 `statusCheckRollup` sub-selection shared by the badge fragment
294/// ([`branch_fragment`]) and the merge-eligibility fragment
295/// ([`merge_branch_fragment`]): every check context, reduced to a verdict by
296/// [`rollup_check_state`]. Factored out so the two fragments cannot drift; kept to a
297/// single third-level connection so the query stays at 1 point (see the module docs).
298const ROLLUP_FRAGMENT: &str = "statusCheckRollup{ contexts(first:100){ nodes{
299          __typename
300          ...on CheckRun{ status conclusion checkSuite{ status } }
301          ...on StatusContext{ state }
302        } } }";
303
304/// The GraphQL fragment resolving one branch: its head OID, the rollup contexts
305/// the verdict is reduced from, and the open PR that heads it.
306///
307/// `associatedPullRequests` is read off the **`Ref`**, filtered to `OPEN`, not off
308/// the `Commit`. On a `Commit` it returns whatever PR introduced that commit — on
309/// `main` that is the last *merged* PR, from an unrelated branch, which would paint
310/// a false badge. On a `Ref` it matches on **head**, reproducing the extension's
311/// "first open PR whose `headRefName` is this branch" rule (verified: `main` is the
312/// base of many open PRs and correctly resolves to nothing).
313fn branch_fragment(alias: &str, branch: &str) -> String {
314    // JSON string escaping is valid GraphQL string escaping, so this is safe for
315    // any branch name git permits.
316    let qualified = Value::String(format!("refs/heads/{branch}"));
317    format!(
318        r"{alias}: ref(qualifiedName:{qualified}){{
319      target{{ ...on Commit{{ oid
320        {ROLLUP_FRAGMENT}
321      }} }}
322      associatedPullRequests(first:1, states:OPEN){{ nodes{{ number isDraft url }} }}
323    }}"
324    )
325}
326
327/// The GraphQL fragment resolving one branch for **merge-queue eligibility** (#1401).
328///
329/// A superset of [`branch_fragment`]'s PR selection: on top of the shared
330/// [`ROLLUP_FRAGMENT`] (for the CI gate) it fetches the fields `enqueuePullRequest`
331/// and the eligibility gates need but the badge cache never carries — the PR's
332/// GraphQL node `id` (the mutation input), `headRefOid` (compared to the local head
333/// to catch a stale UI), `mergeStateStatus` (the conflict gate), and
334/// `mergeQueueEntry` (already-queued ⇒ idempotent success). Kept **out** of the
335/// hot-path badge poll deliberately: this runs only for the handful of worktrees a
336/// user explicitly selects to enqueue, and the op needs fresh data anyway.
337fn merge_branch_fragment(alias: &str, branch: &str) -> String {
338    let qualified = Value::String(format!("refs/heads/{branch}"));
339    format!(
340        r"{alias}: ref(qualifiedName:{qualified}){{
341      target{{ ...on Commit{{ oid
342        {ROLLUP_FRAGMENT}
343      }} }}
344      associatedPullRequests(first:1, states:OPEN){{ nodes{{
345        id number isDraft url headRefOid mergeStateStatus
346        mergeQueueEntry{{ state }}
347      }} }}
348    }}"
349    )
350}
351
352/// Maps a query's `(repo alias index, branch alias index)` back to the target it
353/// was built for, so the reply can be read without re-deriving the aliasing.
354type QueryIndex = HashMap<(usize, usize), PrTarget>;
355
356/// Builds the single aliased query for every target, plus the alias→target index
357/// needed to read the reply back. Targets are grouped by repo so each repo appears
358/// once. Returns `None` when there is nothing to ask.
359fn build_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
360    if targets.is_empty() {
361        return None;
362    }
363    // BTreeMap so aliases are assigned deterministically — the query text is then
364    // stable for a stable target set, which keeps it testable.
365    let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
366    for t in targets {
367        by_repo
368            .entry((t.owner.as_str(), t.name.as_str()))
369            .or_default()
370            .push(t);
371    }
372    let mut index = HashMap::new();
373    let mut repos = Vec::new();
374    for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
375        let mut frags = Vec::new();
376        for (bi, target) in branches.iter().enumerate() {
377            frags.push(branch_fragment(&format!("b{bi}"), &target.branch));
378            index.insert((ri, bi), (*target).clone());
379        }
380        let owner = Value::String((*owner).to_string());
381        let name = Value::String((*name).to_string());
382        repos.push(format!(
383            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
384            frags.join("\n")
385        ));
386    }
387    // Fold the graphql budget into every poll (#1389, fix 8): `rateLimit` is a
388    // meta-field GitHub answers for free (it does not add to the query's own
389    // `cost`), so each PR poll doubles as a live budget reading — and its `cost`
390    // reveals the actual per-call point price, verifying the "1 point" assumption at
391    // scale instead of taking it on faith.
392    Some((
393        format!(
394            "query{{\nrateLimit{{ limit cost remaining used resetAt }}\n{}\n}}",
395            repos.join("\n")
396        ),
397        index,
398    ))
399}
400
401/// The `rateLimit` block folded into every PR-poll reply (#1389, fix 8).
402///
403/// Carries the query's own point `cost` plus the live graphql budget. Every field
404/// is what GitHub's `rateLimit` object reports; `reset` is `resetAt` (ISO-8601)
405/// parsed to a Unix epoch so it drops straight into a
406/// [`RateLimitResource`](crate::github_rate_limit::RateLimitResource).
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub struct QueryRateLimit {
409    /// The point cost GitHub charged for *this* query.
410    pub cost: u64,
411    /// Points spent in the current graphql window.
412    pub used: u64,
413    /// The window ceiling (5,000/hour today).
414    pub limit: u64,
415    /// Points remaining in the window.
416    pub remaining: u64,
417    /// When the window resets, as a Unix epoch (seconds); `0` if `resetAt` was
418    /// absent or unparseable.
419    pub reset: i64,
420}
421
422/// Reads the `rateLimit` block from a poll reply, or `None` when it is absent
423/// (an older query shape, or a reply that carried none). Best-effort: a partial
424/// block missing any of `cost`/`used`/`limit`/`remaining` yields `None` rather
425/// than a half-populated reading.
426fn parse_rate_limit(body: &Value) -> Option<QueryRateLimit> {
427    let rl = body.get("data")?.get("rateLimit")?;
428    let reset = rl
429        .get("resetAt")
430        .and_then(Value::as_str)
431        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
432        .map_or(0, |dt| dt.timestamp());
433    Some(QueryRateLimit {
434        cost: rl.get("cost")?.as_u64()?,
435        used: rl.get("used")?.as_u64()?,
436        limit: rl.get("limit")?.as_u64()?,
437        remaining: rl.get("remaining")?.as_u64()?,
438        reset,
439    })
440}
441
442/// Reads one resolved `ref` node into a badge. `None` for a node without a
443/// readable open PR; [`resolution_from_ref`] decides whether that means "no PR"
444/// (an explicit negative) or "malformed" (unresolved) before delegating here.
445fn badge_from_ref(node: &Value) -> Option<PrBadge> {
446    let pr = node
447        .get("associatedPullRequests")?
448        .get("nodes")?
449        .as_array()?
450        .first()?;
451    let contexts = node
452        .get("target")
453        .and_then(|t| t.get("statusCheckRollup"))
454        .and_then(|r| r.get("contexts"))
455        .and_then(|c| c.get("nodes"))
456        .and_then(Value::as_array)
457        .cloned()
458        .unwrap_or_default();
459    Some(PrBadge {
460        number: pr.get("number").and_then(Value::as_u64)?,
461        is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
462        checks: rollup_check_state(&contexts),
463        url: pr
464            .get("url")
465            .and_then(Value::as_str)
466            .unwrap_or_default()
467            .to_string(),
468        // The commit this verdict is about — the remote branch head the rollup was
469        // read from, not the PR's own `headRefOid` (the same commit, one fewer field
470        // to ask for).
471        head_oid: node
472            .get("target")
473            .and_then(|t| t.get("oid"))
474            .and_then(Value::as_str)
475            .unwrap_or_default()
476            .to_string(),
477    })
478}
479
480/// Reads one **non-null** `ref` node into a resolution: [`PrResolution::NoPr`]
481/// when the PR list is present and empty, a badge for an open PR, and `None` —
482/// *unresolved*, never a negative — for a malformed node. A false negative would
483/// silence the client fallback for a branch that may well have a PR, so anything
484/// we cannot positively read stays unresolved (#1370).
485fn resolution_from_ref(node: &Value) -> Option<PrResolution> {
486    let prs = node
487        .get("associatedPullRequests")?
488        .get("nodes")?
489        .as_array()?;
490    if prs.is_empty() {
491        return Some(PrResolution::NoPr);
492    }
493    badge_from_ref(node).map(PrResolution::Pr)
494}
495
496/// Reads the GraphQL reply back into resolutions, keyed by target.
497///
498/// Tri-state per target (#1370): an open PR yields its badge, a checked branch
499/// with none — including one never pushed, whose aliased `ref` comes back null —
500/// yields [`PrResolution::NoPr`], and a target whose part of the reply is missing
501/// or malformed is simply **absent** (unresolved). Best-effort per branch: one
502/// bad node never sinks the whole poll, and never mints a negative.
503fn parse_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, PrResolution> {
504    let mut out = HashMap::new();
505    let Some(data) = body.get("data") else {
506        return out;
507    };
508    for ((ri, bi), target) in index {
509        // A repo alias absent or null (a repo-level failure) is unresolved — only
510        // an answer *about the ref* may mint a negative.
511        let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
512            continue;
513        };
514        let Some(node) = repo.get(format!("b{bi}")) else {
515            continue;
516        };
517        // A null ref is a branch that does not exist on the remote (never
518        // pushed): checked, and definitively PR-less.
519        if node.is_null() {
520            out.insert(target.clone(), PrResolution::NoPr);
521            continue;
522        }
523        if let Some(resolution) = resolution_from_ref(node) {
524            out.insert(target.clone(), resolution);
525        }
526    }
527    out
528}
529
530/// Resolves `gh`, preferring [`GH_BIN_ENV`], then the first existing well-known
531/// absolute path, then bare `gh` on `PATH`.
532///
533/// Callers should do this **once** (the poller resolves it at spawn) and pass the
534/// result to [`resolve_with`], rather than re-reading the environment per poll.
535#[must_use]
536pub fn resolve_gh_binary() -> PathBuf {
537    resolve_gh_binary_from(std::env::var_os(GH_BIN_ENV), GH_BINARY_CANDIDATES)
538}
539
540/// The testable core of [`resolve_gh_binary`]. Split so the probe order can be
541/// unit-tested without mutating the process environment.
542fn resolve_gh_binary_from(
543    env_override: Option<std::ffi::OsString>,
544    candidates: &[&str],
545) -> PathBuf {
546    if let Some(path) = env_override.filter(|p| !p.is_empty()) {
547        return PathBuf::from(path);
548    }
549    for candidate in candidates {
550        let path = Path::new(candidate);
551        if path.exists() {
552            return path.to_path_buf();
553        }
554    }
555    PathBuf::from("gh")
556}
557
558/// Runs one `gh api graphql` call against `bin`. **Blocking** — callers must be on
559/// a blocking thread, never an async worker.
560fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
561    let query_arg = format!("query={query}");
562    let output = crate::github_metrics::run_gh(
563        bin,
564        ["api", "graphql", "-f", query_arg.as_str()],
565        "api graphql",
566        None,
567    )
568    .with_context(|| {
569        format!(
570            "failed to run {} (is the GitHub CLI installed?)",
571            bin.display()
572        )
573    })?;
574    if !output.status.success() {
575        let stderr = String::from_utf8_lossy(&output.stderr);
576        bail!("gh api graphql failed: {}", stderr.trim());
577    }
578    serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
579}
580
581/// Resolves every target in **one** `gh api graphql` call, using the `gh` at
582/// `bin`.
583///
584/// The binary is a parameter rather than resolved here so callers read the
585/// environment **once** (the poller does it at spawn, like its interval) and so
586/// tests inject a stub without mutating the process environment — two parallel
587/// tests pointing one global env var at different fakes race, and the project is
588/// migrating away from test env locks toward injection (#1030).
589///
590/// **Blocking** — run on a blocking thread. Every target that was successfully
591/// checked appears in the map: [`PrResolution::Pr`] for an open PR,
592/// [`PrResolution::NoPr`] for a branch — pushed or not — with none (#1370). A
593/// target is *absent* only when its part of the reply was missing or malformed,
594/// and a failed call (`Err`, including a GraphQL 200 carrying `errors`) yields no
595/// map at all — so a failed poll can never manufacture negatives.
596pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrResolution>> {
597    resolve_inner(bin, targets).map(|(resolutions, _budget)| resolutions)
598}
599
600/// [`resolve_with`] that **also** returns the poll's folded-in graphql budget
601/// reading (#1389, fix 8).
602///
603/// For the daemon poller to feed the shared
604/// [`RateLimitCache`](crate::github_rate_limit::RateLimitCache). The budget is
605/// `None` when there were no targets (no query ran) or the reply carried no
606/// `rateLimit` block; the resolutions are exactly what [`resolve_with`] returns.
607pub fn resolve_with_budget(
608    bin: &Path,
609    targets: &[PrTarget],
610) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
611    resolve_inner(bin, targets)
612}
613
614/// The shared body of [`resolve_with`] / [`resolve_with_budget`]: one `gh api
615/// graphql` call, parsed into resolutions **and** the optional budget reading.
616fn resolve_inner(
617    bin: &Path,
618    targets: &[PrTarget],
619) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
620    let Some((query, index)) = build_query(targets) else {
621        return Ok((HashMap::new(), None));
622    };
623    let body = run_gh_graphql(bin, &query)?;
624    // A GraphQL 200 can still carry errors; surface them rather than silently
625    // reporting "no badges", which would look identical to "no PRs".
626    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
627        if !errors.is_empty() {
628            bail!("gh api graphql returned errors: {errors:?}");
629        }
630    }
631    Ok((parse_response(&body, &index), parse_rate_limit(&body)))
632}
633
634// --- Merge-queue eligibility & enqueue (#1401) --------------------------------
635
636/// What the merge-queue op needs to know about a branch's open PR.
637///
638/// Resolved **fresh** at enqueue time — never from the badge cache, which carries
639/// neither the PR node `id` the mutation needs nor the current merge state.
640#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct MergeInfo {
642    /// The PR's GraphQL global node id — the `enqueuePullRequest` input.
643    pub pr_id: String,
644    /// The PR number, for display.
645    pub number: u64,
646    /// The PR's web URL.
647    pub url: String,
648    /// Whether the PR is a draft (GitHub refuses to queue a draft).
649    pub is_draft: bool,
650    /// The remote PR head commit — compared to the local head to catch a stale UI.
651    pub head_oid: String,
652    /// GitHub's mergeability verdict, upper-cased (`CLEAN`, `BLOCKED`, `DIRTY`,
653    /// `UNKNOWN`, …); `None` when the field was absent from the reply.
654    pub merge_state: Option<String>,
655    /// Whether the PR is already in the merge queue — an enqueue is then a no-op
656    /// (idempotent success, not a failure).
657    pub already_queued: bool,
658    /// The rolled-up CI verdict on the PR head.
659    pub checks: PrCheckState,
660}
661
662/// Builds the single aliased merge-eligibility query for every target, plus the
663/// alias→target index to read the reply back. Same repo-grouping and aliasing as
664/// [`build_query`], but with [`merge_branch_fragment`] and no `rateLimit` fold —
665/// this is a rare, explicit, small-N call, not the hot-path poll.
666fn build_merge_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
667    if targets.is_empty() {
668        return None;
669    }
670    let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
671    for t in targets {
672        by_repo
673            .entry((t.owner.as_str(), t.name.as_str()))
674            .or_default()
675            .push(t);
676    }
677    let mut index = HashMap::new();
678    let mut repos = Vec::new();
679    for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
680        let mut frags = Vec::new();
681        for (bi, target) in branches.iter().enumerate() {
682            frags.push(merge_branch_fragment(&format!("b{bi}"), &target.branch));
683            index.insert((ri, bi), (*target).clone());
684        }
685        let owner = Value::String((*owner).to_string());
686        let name = Value::String((*name).to_string());
687        repos.push(format!(
688            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
689            frags.join("\n")
690        ));
691    }
692    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
693}
694
695/// Reads one resolved merge `ref` node into a [`MergeInfo`]. `None` — meaning
696/// *no readable open PR*, which the caller treats as "skip, no PR" — for a node
697/// whose PR list is absent/empty or is missing the load-bearing `id`/`number`.
698fn merge_info_from_ref(node: &Value) -> Option<MergeInfo> {
699    let pr = node
700        .get("associatedPullRequests")?
701        .get("nodes")?
702        .as_array()?
703        .first()?;
704    let contexts = node
705        .get("target")
706        .and_then(|t| t.get("statusCheckRollup"))
707        .and_then(|r| r.get("contexts"))
708        .and_then(|c| c.get("nodes"))
709        .and_then(Value::as_array)
710        .cloned()
711        .unwrap_or_default();
712    Some(MergeInfo {
713        pr_id: pr.get("id").and_then(Value::as_str)?.to_string(),
714        number: pr.get("number").and_then(Value::as_u64)?,
715        url: pr
716            .get("url")
717            .and_then(Value::as_str)
718            .unwrap_or_default()
719            .to_string(),
720        is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
721        head_oid: pr
722            .get("headRefOid")
723            .and_then(Value::as_str)
724            .unwrap_or_default()
725            .to_string(),
726        merge_state: pr
727            .get("mergeStateStatus")
728            .and_then(Value::as_str)
729            .map(str::to_ascii_uppercase),
730        already_queued: pr
731            .get("mergeQueueEntry")
732            .is_some_and(|entry| !entry.is_null()),
733        checks: rollup_check_state(&contexts),
734    })
735}
736
737/// Reads a merge-eligibility reply back into [`MergeInfo`]s, keyed by target. A
738/// target whose part of the reply is missing, null, or PR-less is simply **absent**
739/// — the caller reports that as a `no-pr` skip.
740fn parse_merge_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, MergeInfo> {
741    let mut out = HashMap::new();
742    let Some(data) = body.get("data") else {
743        return out;
744    };
745    for ((ri, bi), target) in index {
746        let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
747            continue;
748        };
749        let Some(node) = repo.get(format!("b{bi}")).filter(|n| !n.is_null()) else {
750            continue;
751        };
752        if let Some(info) = merge_info_from_ref(node) {
753            out.insert(target.clone(), info);
754        }
755    }
756    out
757}
758
759/// Resolves merge-queue eligibility facts for every target in **one** `gh api
760/// graphql` call. **Blocking** — run on a blocking thread.
761///
762/// Only targets with a readable open PR appear in the map; an absent target means
763/// no open PR (or an unreadable reply), which the merge-queue op treats as a
764/// `no-pr` skip. A failed call (including a GraphQL 200 carrying `errors`) is `Err`,
765/// so the whole batch reports the error rather than silently enqueuing nothing.
766pub fn resolve_merge_targets(
767    bin: &Path,
768    targets: &[PrTarget],
769) -> Result<HashMap<PrTarget, MergeInfo>> {
770    let Some((query, index)) = build_merge_query(targets) else {
771        return Ok(HashMap::new());
772    };
773    let body = run_gh_graphql(bin, &query)?;
774    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
775        if !errors.is_empty() {
776            bail!("gh api graphql returned errors: {errors:?}");
777        }
778    }
779    Ok(parse_merge_response(&body, &index))
780}
781
782/// The outcome of a single `enqueuePullRequest` mutation.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum EnqueueOutcome {
785    /// The PR is now in the merge queue; the optional string is `mergeQueueEntry`'s
786    /// reported `state`.
787    Queued(Option<String>),
788    /// GitHub rejected the enqueue — merge queue disabled on the repo, the PR not
789    /// mergeable / conflicting, or insufficient permissions. The string is the
790    /// human-readable reason, surfaced to the caller as a per-PR failure (never a
791    /// panic, never a batch-sinking error).
792    Rejected(String),
793}
794
795/// Joins one-or-more GraphQL `errors[].message` strings into a single reason.
796fn enqueue_error_message(errors: &[Value]) -> String {
797    let joined = errors
798        .iter()
799        .filter_map(|e| e.get("message").and_then(Value::as_str))
800        .collect::<Vec<_>>()
801        .join("; ");
802    if joined.is_empty() {
803        "enqueue rejected by GitHub".to_string()
804    } else {
805        joined
806    }
807}
808
809/// Enqueues one PR into its repo's merge queue via the `enqueuePullRequest`
810/// mutation. **Blocking** — run on a blocking thread.
811///
812/// Unlike [`run_gh_graphql`], a GraphQL error here is **not** a hard `Err`: `gh`
813/// exits non-zero on a rejected mutation, and we map that (and a 200-with-`errors`)
814/// to [`EnqueueOutcome::Rejected`] so one un-enqueuable PR lands in the batch's
815/// `failed[]` list while the rest proceed. `Err` is reserved for "could not run
816/// `gh` at all" (e.g. the binary is missing).
817pub fn enqueue_pull_request(bin: &Path, pr_node_id: &str) -> Result<EnqueueOutcome> {
818    // JSON string escaping is valid GraphQL string escaping (as in `branch_fragment`).
819    let id_lit = Value::String(pr_node_id.to_string());
820    let mutation =
821        format!("mutation{{ enqueuePullRequest(input:{{pullRequestId:{id_lit}}}){{ mergeQueueEntry{{ state }} }} }}");
822    let query_arg = format!("query={mutation}");
823    let output = crate::github_metrics::run_gh(
824        bin,
825        ["api", "graphql", "-f", query_arg.as_str()],
826        "api graphql",
827        None,
828    )
829    .with_context(|| {
830        format!(
831            "failed to run {} (is the GitHub CLI installed?)",
832            bin.display()
833        )
834    })?;
835    if !output.status.success() {
836        // A rejected mutation exits non-zero; the reason is in the JSON `errors`
837        // body (preferred) or, failing that, stderr.
838        let msg = serde_json::from_slice::<Value>(&output.stdout)
839            .ok()
840            .and_then(|body| {
841                body.get("errors")
842                    .and_then(Value::as_array)
843                    .filter(|errs| !errs.is_empty())
844                    .map(|errs| enqueue_error_message(errs))
845            })
846            .unwrap_or_else(|| String::from_utf8_lossy(&output.stderr).trim().to_string());
847        return Ok(EnqueueOutcome::Rejected(msg));
848    }
849    let body: Value =
850        serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")?;
851    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
852        if !errors.is_empty() {
853            return Ok(EnqueueOutcome::Rejected(enqueue_error_message(errors)));
854        }
855    }
856    let state = body
857        .pointer("/data/enqueuePullRequest/mergeQueueEntry/state")
858        .and_then(Value::as_str)
859        .map(str::to_string);
860    Ok(EnqueueOutcome::Queued(state))
861}
862
863/// The poller-written, snapshot-read resolution cache.
864///
865/// A plain `std::Mutex` map: writes come from the poll loop, reads from the tree
866/// snapshot build. The lock is never held across an `.await` — every method takes
867/// it, finishes, and drops it.
868#[derive(Debug, Default)]
869pub struct PrStatusCache {
870    resolutions: Mutex<HashMap<PrTarget, PrResolution>>,
871}
872
873impl PrStatusCache {
874    /// An empty cache. Until the first poll lands, every lookup misses and the
875    /// tree simply renders no badge — and no negative either (#1370).
876    #[must_use]
877    pub fn new() -> Self {
878        Self::default()
879    }
880
881    /// The resolution for one (repo, branch): a badge, an explicit no-PR, or
882    /// `None` when never successfully checked.
883    #[must_use]
884    pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrResolution> {
885        let key = PrTarget {
886            owner: owner.to_string(),
887            name: name.to_string(),
888            branch: branch.to_string(),
889        };
890        self.lock().get(&key).cloned()
891    }
892
893    /// Replaces the cache wholesale, returning whether anything actually changed.
894    ///
895    /// The bool is load-bearing: the caller bumps the registry's change-notify only
896    /// when it is `true`. Bumping unconditionally would defeat the server's
897    /// diff-and-drop and re-push an identical snapshot to every window on every
898    /// poll — the cost this whole design exists to avoid. A first round of
899    /// negatives *is* a change — the one push that delivers them — after which
900    /// identical polls stay silent.
901    pub fn replace(&self, next: HashMap<PrTarget, PrResolution>) -> bool {
902        let mut guard = self.lock();
903        if *guard == next {
904            return false;
905        }
906        *guard = next;
907        true
908    }
909
910    /// Drops every cached resolution whose target is absent from `keep`, returning
911    /// whether anything was removed.
912    ///
913    /// Lets the poller prune the verdicts of worktrees that closed (or repos whose
914    /// lease lapsed) **without** spending a `gh` call to re-resolve the survivors
915    /// (#1389, fix 1) — the wholesale [`replace`](Self::replace) only prunes as a
916    /// side effect of a fetch, which a pure removal must never trigger. No caller
917    /// bumps on the return today (a removed target's row is already gone from the
918    /// tree, so nothing re-renders), but the bool is reported for symmetry with
919    /// `replace` and to keep the prune observable in tests.
920    pub fn retain_targets(&self, keep: &HashSet<PrTarget>) -> bool {
921        let mut guard = self.lock();
922        let before = guard.len();
923        guard.retain(|target, _| keep.contains(target));
924        guard.len() != before
925    }
926
927    /// Every cached (target, resolution) pair, cloned out for persistence (#1389,
928    /// fix 4). Ordering is unspecified (a `HashMap`); the caller sorts if it needs
929    /// a stable on-disk form.
930    #[must_use]
931    pub fn entries(&self) -> Vec<(PrTarget, PrResolution)> {
932        self.lock()
933            .iter()
934            .map(|(t, r)| (t.clone(), r.clone()))
935            .collect()
936    }
937
938    /// Seeds the cache from a persisted set, **without** bumping any change-notify.
939    ///
940    /// Called once at daemon startup (before any window subscribes) so restored
941    /// badges render on the first tree snapshot rather than after the first poll
942    /// (#1389, fix 4) — the [`crate::pr_status`] analogue of
943    /// `WorktreesRegistry::seed_polling`. Existing entries with the same target are
944    /// overwritten; the cache is empty at that point, so in practice this inserts.
945    pub fn seed(&self, entries: impl IntoIterator<Item = (PrTarget, PrResolution)>) {
946        let mut guard = self.lock();
947        for (target, resolution) in entries {
948            guard.insert(target, resolution);
949        }
950    }
951
952    /// Whether any cached badge is still pending — the poller's cadence signal. A
953    /// negative is terminal and never holds the fast cadence.
954    #[must_use]
955    pub fn any_pending(&self) -> bool {
956        self.lock()
957            .values()
958            .any(|r| matches!(r, PrResolution::Pr(b) if b.checks == PrCheckState::Pending))
959    }
960
961    /// Poison-tolerant lock: a panicking holder must not wedge the badge cache,
962    /// which is best-effort decoration.
963    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrResolution>> {
964        self.resolutions
965            .lock()
966            .unwrap_or_else(PoisonError::into_inner)
967    }
968}
969
970#[cfg(test)]
971#[allow(clippy::unwrap_used, clippy::expect_used)]
972mod tests {
973    use super::*;
974    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
975    use serde_json::json;
976    use std::sync::MutexGuard;
977
978    fn target(branch: &str) -> PrTarget {
979        PrTarget {
980            owner: "rust-works".into(),
981            name: "omni-dev".into(),
982            branch: branch.into(),
983        }
984    }
985
986    // --- Reducer: ported verbatim from the extension's github.test.ts so the
987    //     daemon-side move is behaviour-preserving (#1337 PR 2). ---
988
989    #[test]
990    fn rollup_is_none_for_an_empty_rollup() {
991        assert_eq!(rollup_check_state(&[]), PrCheckState::None);
992    }
993
994    #[test]
995    fn rollup_reads_completed_check_run_conclusions() {
996        for (conclusion, want) in [
997            ("SUCCESS", PrCheckState::Success),
998            ("NEUTRAL", PrCheckState::Success),
999            ("SKIPPED", PrCheckState::Success),
1000            ("FAILURE", PrCheckState::Failure),
1001            ("CANCELLED", PrCheckState::Failure),
1002            ("TIMED_OUT", PrCheckState::Failure),
1003            ("ACTION_REQUIRED", PrCheckState::Failure),
1004            ("STARTUP_FAILURE", PrCheckState::Failure),
1005            ("STALE", PrCheckState::Failure),
1006        ] {
1007            let entry =
1008                json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
1009            assert_eq!(
1010                rollup_check_state(&[entry]),
1011                want,
1012                "conclusion {conclusion} should be {want:?}"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn rollup_treats_an_incomplete_check_run_as_pending() {
1019        // The conclusion is null while running; the status decides.
1020        for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
1021            let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
1022            assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1023        }
1024    }
1025
1026    #[test]
1027    fn rollup_reads_status_context_states() {
1028        // A legacy StatusContext has no `status`, so `state` decides.
1029        for (state, want) in [
1030            ("SUCCESS", PrCheckState::Success),
1031            ("FAILURE", PrCheckState::Failure),
1032            ("ERROR", PrCheckState::Failure),
1033            ("PENDING", PrCheckState::Pending),
1034            ("EXPECTED", PrCheckState::Pending),
1035        ] {
1036            let entry = json!({"__typename":"StatusContext","state":state});
1037            assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
1038        }
1039    }
1040
1041    #[test]
1042    fn rollup_never_reads_an_unknown_value_as_a_pass() {
1043        // The load-bearing rule: anything unrecognised is pending, never success.
1044        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
1045        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1046        // A completed-but-unset conclusion likewise.
1047        let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
1048        assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1049    }
1050
1051    #[test]
1052    fn rollup_precedence_is_failure_then_pending_then_success() {
1053        let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
1054        let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
1055        let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
1056        // Failure dominates everything, in either order.
1057        assert_eq!(
1058            rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
1059            PrCheckState::Failure
1060        );
1061        assert_eq!(
1062            rollup_check_state(&[bad, ok.clone()]),
1063            PrCheckState::Failure
1064        );
1065        // Pending beats success.
1066        assert_eq!(
1067            rollup_check_state(&[ok.clone(), run]),
1068            PrCheckState::Pending
1069        );
1070        assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
1071    }
1072
1073    // --- Suite awareness: the `needs:`-gate false green ---
1074
1075    fn run(conclusion: &str, suite: Option<&str>) -> Value {
1076        let mut e = json!({
1077            "__typename": "CheckRun",
1078            "status": "COMPLETED",
1079            "conclusion": conclusion,
1080        });
1081        if let Some(s) = suite {
1082            e["checkSuite"] = json!({ "status": s });
1083        }
1084        e
1085    }
1086
1087    #[test]
1088    fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
1089        // The exact shape observed on PRs #1294/#1329: every check run that exists
1090        // is green — GitHub's own rollup `state` reads SUCCESS — but the CI suite is
1091        // still spawning the `needs: gate` fan-out. Reporting success here is the
1092        // false ✓ this issue is about.
1093        let contexts = vec![
1094            run("SUCCESS", Some("IN_PROGRESS")),
1095            run("SUCCESS", Some("IN_PROGRESS")),
1096        ];
1097        assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
1098        // QUEUED counts too — the suite exists but has not started its fan-out.
1099        assert_eq!(
1100            rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
1101            PrCheckState::Pending
1102        );
1103    }
1104
1105    #[test]
1106    fn rollup_is_success_once_every_backing_suite_is_terminal() {
1107        let contexts = vec![
1108            run("SUCCESS", Some("COMPLETED")),
1109            run("SKIPPED", Some("COMPLETED")),
1110        ];
1111        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1112    }
1113
1114    #[test]
1115    fn rollup_ignores_a_zero_run_zombie_suite() {
1116        // codecov leaves a QUEUED suite with **zero** check runs on every PR — one
1117        // was still queued after 3.7 days. A rule keyed on "any non-terminal suite"
1118        // would pin such a PR yellow forever. Keying on suites reachable *through a
1119        // check run* excludes it structurally: with no runs, it never appears in the
1120        // rollup at all. So a rollup whose runs all carry terminal suites is green,
1121        // even though a zombie suite exists on the commit.
1122        let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
1123        assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1124    }
1125
1126    #[test]
1127    fn rollup_tolerates_entries_without_suite_information() {
1128        // A legacy StatusContext has no `checkSuite`, and neither does a CheckRun if
1129        // the field is ever absent. Missing suite info must not imply "still
1130        // running", or every StatusContext-only repo pins yellow.
1131        assert_eq!(
1132            rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
1133            PrCheckState::Success
1134        );
1135        assert_eq!(
1136            rollup_check_state(&[run("SUCCESS", None)]),
1137            PrCheckState::Success
1138        );
1139        // An empty suite status is not a running suite either.
1140        assert_eq!(
1141            rollup_check_state(&[run("SUCCESS", Some(""))]),
1142            PrCheckState::Success
1143        );
1144    }
1145
1146    #[test]
1147    fn rollup_failure_still_dominates_a_running_suite() {
1148        // A red check is red regardless of what else is still spawning.
1149        let contexts = vec![
1150            run("FAILURE", Some("IN_PROGRESS")),
1151            run("SUCCESS", Some("IN_PROGRESS")),
1152        ];
1153        assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
1154    }
1155
1156    #[test]
1157    fn build_query_asks_for_the_backing_suite_status() {
1158        let (query, _) = build_query(&[target("main")]).unwrap();
1159        assert!(query.contains("checkSuite{ status }"), "{query}");
1160    }
1161
1162    #[test]
1163    fn build_query_folds_in_the_rate_limit_block() {
1164        // #1389, fix 8: every poll carries a free budget reading.
1165        let (query, _) = build_query(&[target("main")]).unwrap();
1166        assert!(
1167            query.contains("rateLimit{ limit cost remaining used resetAt }"),
1168            "{query}"
1169        );
1170    }
1171
1172    #[test]
1173    fn parse_rate_limit_reads_the_folded_budget_block() {
1174        let body = json!({"data":{"rateLimit":
1175            {"limit":5000,"cost":1,"remaining":4970,"used":30,"resetAt":"2026-07-21T16:00:00Z"}}});
1176        let rl = parse_rate_limit(&body).expect("a complete block should parse");
1177        assert_eq!(rl.cost, 1);
1178        assert_eq!(rl.used, 30);
1179        assert_eq!(rl.limit, 5000);
1180        assert_eq!(rl.remaining, 4970);
1181        assert!(rl.reset > 0, "resetAt should parse to an epoch");
1182        // An older query shape (no block) reads as `None`, not a zeroed budget.
1183        assert!(parse_rate_limit(&json!({"data":{"r0":{}}})).is_none());
1184        // A partial block (missing `used`) is `None`, never half-populated.
1185        assert!(parse_rate_limit(
1186            &json!({"data":{"rateLimit":{"limit":5000,"cost":1,"remaining":4970}}})
1187        )
1188        .is_none());
1189    }
1190
1191    // --- Query shape ---
1192
1193    #[test]
1194    fn build_query_is_none_without_targets() {
1195        assert!(build_query(&[]).is_none());
1196    }
1197
1198    #[test]
1199    fn build_query_groups_branches_under_one_repo_alias() {
1200        let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
1201        // One repo → one `repository(...)` block, two `ref(...)` aliases.
1202        assert_eq!(query.matches("repository(owner:").count(), 1);
1203        assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
1204        assert!(query.contains(r#""refs/heads/main""#), "{query}");
1205        assert!(query.contains(r#""refs/heads/feature""#), "{query}");
1206        assert_eq!(index.len(), 2);
1207    }
1208
1209    #[test]
1210    fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
1211        // Guards the semantic trap: `Commit.associatedPullRequests` returns the PR
1212        // that *introduced* the commit — on `main`, the last merged PR from an
1213        // unrelated branch — which would paint a false badge. It must be read off
1214        // the Ref, filtered to OPEN, which matches on head.
1215        let (query, _) = build_query(&[target("main")]).unwrap();
1216        assert!(
1217            query.contains("associatedPullRequests(first:1, states:OPEN)"),
1218            "{query}"
1219        );
1220        // The PR lookup must sit outside the `target{...on Commit{...}}` block.
1221        let commit_block = query.find("...on Commit").unwrap();
1222        let pr_lookup = query.find("associatedPullRequests").unwrap();
1223        assert!(
1224            pr_lookup > commit_block,
1225            "PR lookup must be on the Ref, after the Commit block"
1226        );
1227    }
1228
1229    #[test]
1230    fn build_query_separates_distinct_repos() {
1231        let a = PrTarget {
1232            owner: "o1".into(),
1233            name: "r1".into(),
1234            branch: "main".into(),
1235        };
1236        let b = PrTarget {
1237            owner: "o2".into(),
1238            name: "r2".into(),
1239            branch: "main".into(),
1240        };
1241        let (query, index) = build_query(&[a, b]).unwrap();
1242        assert_eq!(query.matches("repository(owner:").count(), 2);
1243        assert_eq!(index.len(), 2);
1244    }
1245
1246    #[test]
1247    fn build_query_escapes_branch_names() {
1248        // Defensive: JSON escaping keeps a quote in a ref name from breaking out of
1249        // the GraphQL string literal.
1250        let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
1251        assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
1252    }
1253
1254    // --- Reply parsing ---
1255
1256    #[test]
1257    fn parse_response_reads_badges_and_resolves_absent_refs_as_negatives() {
1258        let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
1259        let (_, index) = build_query(&targets).unwrap();
1260        // Aliases are assigned in BTreeMap order of (owner,name) then input order.
1261        let body = json!({"data":{"r0":{
1262            "b0": {
1263                "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
1264                    {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1265                ]}}},
1266                "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
1267            },
1268            // An unpushed branch resolves to a null ref — checked, and PR-less.
1269            "b1": null,
1270            // Pushed, but no open PR heads it.
1271            "b2": {
1272                "target": {"oid":"def","statusCheckRollup":null},
1273                "associatedPullRequests":{"nodes":[]}
1274            }
1275        }}});
1276        let out = parse_response(&body, &index);
1277        assert_eq!(out.len(), 3, "{out:?}");
1278        let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1279            panic!("expected a badge for feature: {out:?}");
1280        };
1281        assert_eq!(badge.number, 65);
1282        assert!(badge.is_draft);
1283        assert_eq!(badge.checks, PrCheckState::Success);
1284        assert_eq!(badge.url, "u");
1285        assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1286        assert_eq!(out.get(&target("no-pr")), Some(&PrResolution::NoPr));
1287    }
1288
1289    #[test]
1290    fn parse_response_reports_a_pushed_branch_with_no_pr_as_a_negative() {
1291        let (_, index) = build_query(&[target("quiet")]).unwrap();
1292        let body = json!({"data":{"r0":{"b0":{
1293            "target": {"oid":"abc","statusCheckRollup":null},
1294            "associatedPullRequests":{"nodes":[]}
1295        }}}});
1296        let out = parse_response(&body, &index);
1297        assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1298    }
1299
1300    #[test]
1301    fn parse_response_reports_an_unpushed_branch_as_a_negative() {
1302        let (_, index) = build_query(&[target("local-only")]).unwrap();
1303        let body = json!({"data":{"r0":{"b0":null}}});
1304        let out = parse_response(&body, &index);
1305        assert_eq!(out.get(&target("local-only")), Some(&PrResolution::NoPr));
1306    }
1307
1308    #[test]
1309    fn parse_response_leaves_a_missing_alias_unresolved_rather_than_negative() {
1310        // Only an answer about the ref may mint a negative: a reply missing the
1311        // ref alias, or with a null repo alias (a repo-level failure), says
1312        // nothing about the branch — a false NoPr would silence the client
1313        // fallback for a branch that may have a PR.
1314        let (_, index) = build_query(&[target("x")]).unwrap();
1315        assert!(parse_response(&json!({"data":{"r0":{}}}), &index).is_empty());
1316        assert!(parse_response(&json!({"data":{"r0":null}}), &index).is_empty());
1317    }
1318
1319    #[test]
1320    fn parse_response_leaves_a_malformed_pr_node_unresolved_rather_than_negative() {
1321        let (_, index) = build_query(&[target("x")]).unwrap();
1322        // The PR list is non-empty, so this is not "no PR" — but the node has no
1323        // readable number, so it is not a badge either. It must stay unresolved.
1324        let body = json!({"data":{"r0":{"b0":{
1325            "target": {"oid":"abc","statusCheckRollup":null},
1326            "associatedPullRequests":{"nodes":[{"isDraft":false,"url":"u"}]}
1327        }}}});
1328        assert!(parse_response(&body, &index).is_empty());
1329    }
1330
1331    #[test]
1332    fn parse_response_reads_a_pr_with_no_checks_as_none() {
1333        let targets = vec![target("feature")];
1334        let (_, index) = build_query(&targets).unwrap();
1335        let body = json!({"data":{"r0":{"b0":{
1336            "target": {"oid":"abc","statusCheckRollup":null},
1337            "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
1338        }}}});
1339        let out = parse_response(&body, &index);
1340        // A PR with no checks is still a PR — PrCheckState::None, never NoPr.
1341        let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1342            panic!("expected a badge: {out:?}");
1343        };
1344        assert_eq!(badge.checks, PrCheckState::None);
1345    }
1346
1347    #[test]
1348    fn parse_response_tolerates_a_missing_data_block() {
1349        // A reply without `data` resolves nothing — and mints no negatives.
1350        let (_, index) = build_query(&[target("x")]).unwrap();
1351        assert!(parse_response(&json!({}), &index).is_empty());
1352    }
1353
1354    // --- Wire shape ---
1355
1356    #[test]
1357    fn badge_serializes_is_draft_as_camel_case() {
1358        // The extension's PrBadge inherited `isDraft` from gh's JSON. Serializing it
1359        // as `is_draft` would silently drop the draft marker on every row.
1360        let badge = PrBadge {
1361            number: 65,
1362            is_draft: true,
1363            checks: PrCheckState::Pending,
1364            url: "u".into(),
1365            head_oid: String::new(),
1366        };
1367        let v = serde_json::to_value(&badge).unwrap();
1368        assert_eq!(v["isDraft"], json!(true));
1369        assert_eq!(v["checks"], json!("pending"));
1370        assert!(v.get("is_draft").is_none(), "{v}");
1371    }
1372
1373    #[test]
1374    fn check_state_serializes_lowercase() {
1375        for (state, want) in [
1376            (PrCheckState::Success, "success"),
1377            (PrCheckState::Failure, "failure"),
1378            (PrCheckState::Pending, "pending"),
1379            (PrCheckState::None, "none"),
1380        ] {
1381            assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
1382        }
1383    }
1384
1385    // --- Binary resolution ---
1386
1387    #[test]
1388    fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
1389        assert_eq!(
1390            resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
1391            PathBuf::from("/custom/gh")
1392        );
1393        let existing = tempfile::NamedTempFile::new().unwrap();
1394        let existing_path = existing.path().to_str().unwrap();
1395        assert_eq!(
1396            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
1397            PathBuf::from(existing_path)
1398        );
1399        assert_eq!(
1400            resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
1401            PathBuf::from("gh")
1402        );
1403        // An empty override falls through rather than resolving to "".
1404        assert_eq!(
1405            resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
1406            PathBuf::from("gh")
1407        );
1408        // The real-env wrapper resolves without panicking.
1409        let _ = resolve_gh_binary();
1410    }
1411
1412    // --- resolve_with: the degradation contract ---
1413    //
1414    // Badges are decoration: a missing, unauthenticated, or failing `gh` must
1415    // surface an error to the poller (which backs off and keeps the last good
1416    // badges) and never panic or hang. These cover the paths that decide that.
1417
1418    /// Writes an executable stub standing in for `gh`, printing `stdout` and
1419    /// exiting `code`.
1420    ///
1421    /// Returns the shim serialisation lock alongside the path: the caller
1422    /// **must** hold the guard until it has finished exec'ing the stub, so
1423    /// concurrent shim subprocesses stay bounded. That lock does **not** close
1424    /// the `ETXTBSY` ("Text file busy") race — writing an executable and then
1425    /// `execve`ing it races every other thread that forks, which never takes
1426    /// this lock — so the caller also runs the exec through [`retry_on_etxtbsy`]
1427    /// (#642, #1344, #1348).
1428    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1429        let guard = shim_lock();
1430        let path = dir.join("fake-gh");
1431        write_exec_script(
1432            &path,
1433            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
1434        );
1435        (path, guard)
1436    }
1437
1438    #[test]
1439    fn resolve_with_asks_nothing_for_no_targets() {
1440        // No branches to resolve → no subprocess at all. The binary is deliberately
1441        // bogus: if this spawned anything it would error instead of returning empty.
1442        let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1443        assert!(out.is_empty());
1444    }
1445
1446    #[test]
1447    fn resolve_with_errors_when_gh_is_missing() {
1448        // The common real case: `gh` not installed, or absent from launchd's
1449        // minimal PATH.
1450        let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
1451        let msg = format!("{err:#}");
1452        assert!(msg.contains("failed to run"), "{msg}");
1453        assert!(msg.contains("GitHub CLI"), "{msg}");
1454    }
1455
1456    #[test]
1457    fn resolve_with_errors_on_a_nonzero_exit() {
1458        // e.g. `gh auth login` never run: gh exits non-zero and explains on stderr.
1459        let dir = tempfile::tempdir().unwrap();
1460        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1461        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1462        assert!(
1463            format!("{err:#}").contains("gh api graphql failed"),
1464            "{err:#}"
1465        );
1466    }
1467
1468    #[test]
1469    fn resolve_with_errors_on_unparseable_output() {
1470        let dir = tempfile::tempdir().unwrap();
1471        let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
1472        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1473        assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
1474    }
1475
1476    #[test]
1477    fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
1478        // A GraphQL 200 can still carry errors (a bad field, a rate limit). Reading
1479        // that as an empty result would be indistinguishable from "no open PRs" and
1480        // would silently blank every badge, so it must be an error. Doubly
1481        // load-bearing since #1370: the hard Err is what guarantees a failed poll
1482        // can never manufacture NoPr negatives.
1483        let dir = tempfile::tempdir().unwrap();
1484        let (bin, _shim) = fake_gh(
1485            dir.path(),
1486            r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
1487            0,
1488        );
1489        let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1490        let msg = format!("{err:#}");
1491        assert!(msg.contains("returned errors"), "{msg}");
1492        assert!(msg.contains("rate limit"), "{msg}");
1493    }
1494
1495    #[test]
1496    fn resolve_with_ignores_an_empty_errors_array() {
1497        // Some responses carry `errors: []`; that is a success, not a failure.
1498        let dir = tempfile::tempdir().unwrap();
1499        let (bin, _shim) = fake_gh(
1500            dir.path(),
1501            r#"{"errors":[],"data":{"r0":{"b0":{
1502                "target":{"oid":"a","statusCheckRollup":null},
1503                "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
1504            0,
1505        );
1506        let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1507        let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1508            panic!("expected a badge: {out:?}");
1509        };
1510        assert_eq!(badge.number, 9);
1511    }
1512
1513    #[test]
1514    fn resolve_with_reads_a_real_reply_end_to_end() {
1515        let dir = tempfile::tempdir().unwrap();
1516        let (bin, _shim) = fake_gh(
1517            dir.path(),
1518            r#"{"data":{"r0":{"b0":{
1519                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1520                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
1521                ]}}},
1522                "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
1523            0,
1524        );
1525        let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1526        let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1527            panic!("expected a badge: {out:?}");
1528        };
1529        assert_eq!(badge.number, 42);
1530        assert!(badge.is_draft);
1531        assert_eq!(badge.checks, PrCheckState::Failure);
1532    }
1533
1534    #[test]
1535    fn resolve_with_reports_negatives_alongside_badges_end_to_end() {
1536        let dir = tempfile::tempdir().unwrap();
1537        let (bin, _shim) = fake_gh(
1538            dir.path(),
1539            r#"{"data":{"r0":{
1540                "b0":{
1541                    "target":{"oid":"a","statusCheckRollup":null},
1542                    "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}},
1543                "b1":null,
1544                "b2":{
1545                    "target":{"oid":"b","statusCheckRollup":null},
1546                    "associatedPullRequests":{"nodes":[]}}}}}"#,
1547            0,
1548        );
1549        let targets = vec![target("main"), target("unpushed"), target("quiet")];
1550        let out = retry_on_etxtbsy(|| resolve_with(&bin, &targets)).unwrap();
1551        assert_eq!(out.len(), 3, "{out:?}");
1552        assert!(
1553            matches!(out.get(&target("main")), Some(PrResolution::Pr(b)) if b.number == 7),
1554            "{out:?}"
1555        );
1556        assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1557        assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1558    }
1559
1560    // --- Cache ---
1561
1562    /// A pending badge wrapped as a resolution, for cache fixtures.
1563    fn pending_pr(number: u64) -> PrResolution {
1564        PrResolution::Pr(PrBadge {
1565            number,
1566            is_draft: false,
1567            checks: PrCheckState::Pending,
1568            url: "u".into(),
1569            head_oid: String::new(),
1570        })
1571    }
1572
1573    #[test]
1574    fn cache_replace_reports_whether_anything_changed() {
1575        let cache = PrStatusCache::new();
1576        let mut map = HashMap::new();
1577        map.insert(target("f"), pending_pr(1));
1578
1579        // First write is a change.
1580        assert!(cache.replace(map.clone()));
1581        // An identical write is not — this is what keeps the poller from re-pushing
1582        // an unchanged snapshot to every window on every tick.
1583        assert!(!cache.replace(map.clone()));
1584
1585        // A changed verdict is a change.
1586        let mut moved = map.clone();
1587        if let Some(PrResolution::Pr(badge)) = moved.get_mut(&target("f")) {
1588            badge.checks = PrCheckState::Success;
1589        }
1590        assert!(cache.replace(moved));
1591        // Emptying is a change.
1592        assert!(cache.replace(HashMap::new()));
1593        assert!(!cache.replace(HashMap::new()));
1594    }
1595
1596    #[test]
1597    fn cache_replace_counts_a_new_negative_as_a_change() {
1598        let cache = PrStatusCache::new();
1599        let mut negatives = HashMap::new();
1600        negatives.insert(target("f"), PrResolution::NoPr);
1601
1602        // The first round of negatives is a change — the one push that delivers
1603        // them to clients so their fallback goes quiet.
1604        assert!(cache.replace(negatives.clone()));
1605        // Re-polling the same answer is not.
1606        assert!(!cache.replace(negatives.clone()));
1607
1608        // A PR opening (negative → badge) and closing (badge → negative) are both
1609        // real transitions.
1610        let mut opened = HashMap::new();
1611        opened.insert(target("f"), pending_pr(1));
1612        assert!(cache.replace(opened));
1613        assert!(cache.replace(negatives));
1614    }
1615
1616    #[test]
1617    fn cache_any_pending_ignores_negative_resolutions() {
1618        // A negative is terminal: it must never hold the poller's fast cadence.
1619        let cache = PrStatusCache::new();
1620        let mut map = HashMap::new();
1621        map.insert(target("f"), PrResolution::NoPr);
1622        cache.replace(map);
1623        assert!(!cache.any_pending());
1624    }
1625
1626    #[test]
1627    fn cache_get_and_any_pending() {
1628        let cache = PrStatusCache::new();
1629        assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1630        assert!(!cache.any_pending());
1631
1632        let mut map = HashMap::new();
1633        map.insert(target("f"), pending_pr(1));
1634        cache.replace(map.clone());
1635        assert!(
1636            matches!(
1637                cache.get("rust-works", "omni-dev", "f"),
1638                Some(PrResolution::Pr(b)) if b.number == 1
1639            ),
1640            "expected the cached badge back"
1641        );
1642        assert!(cache.any_pending());
1643        // A miss on a branch we never resolved.
1644        assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1645
1646        if let Some(PrResolution::Pr(badge)) = map.get_mut(&target("f")) {
1647            badge.checks = PrCheckState::Success;
1648        }
1649        cache.replace(map);
1650        assert!(!cache.any_pending());
1651    }
1652
1653    #[test]
1654    fn cache_retain_targets_drops_only_the_vanished_entries() {
1655        // A pure removal (a worktree closed) must prune its verdict without a fetch
1656        // (#1389, fix 1), leaving the survivors untouched.
1657        let cache = PrStatusCache::new();
1658        let mut map = HashMap::new();
1659        map.insert(target("keep"), pending_pr(1));
1660        map.insert(target("drop"), pending_pr(2));
1661        cache.replace(map);
1662
1663        let keep: HashSet<PrTarget> = std::iter::once(target("keep")).collect();
1664        assert!(cache.retain_targets(&keep), "a target was removed");
1665        assert!(cache.get("rust-works", "omni-dev", "keep").is_some());
1666        assert!(cache.get("rust-works", "omni-dev", "drop").is_none());
1667        // Idempotent: nothing left to remove reports no change.
1668        assert!(!cache.retain_targets(&keep));
1669    }
1670
1671    #[test]
1672    fn cache_seed_then_entries_round_trips_without_a_bump() {
1673        // A warm start seeds restored verdicts so they render before the first poll
1674        // (#1389, fix 4); `entries` reads them back for the next persist.
1675        let cache = PrStatusCache::new();
1676        cache.seed([
1677            (target("f"), pending_pr(7)),
1678            (target("g"), PrResolution::NoPr),
1679        ]);
1680        assert!(matches!(
1681            cache.get("rust-works", "omni-dev", "f"),
1682            Some(PrResolution::Pr(b)) if b.number == 7
1683        ));
1684        assert!(matches!(
1685            cache.get("rust-works", "omni-dev", "g"),
1686            Some(PrResolution::NoPr)
1687        ));
1688        let mut entries = cache.entries();
1689        entries.sort_by(|a, b| a.0.cmp(&b.0));
1690        assert_eq!(entries.len(), 2);
1691        assert_eq!(entries[0].0, target("f"));
1692        assert_eq!(entries[1].0, target("g"));
1693    }
1694
1695    // --- Merge-queue eligibility & enqueue (#1401) ---
1696
1697    #[test]
1698    fn merge_query_asks_for_the_enqueue_fields() {
1699        let (query, index) = build_merge_query(&[target("main")]).unwrap();
1700        // The fields the badge poll never asks for but enqueue/eligibility need.
1701        for field in [
1702            "id",
1703            "headRefOid",
1704            "mergeStateStatus",
1705            "mergeQueueEntry{ state }",
1706        ] {
1707            assert!(query.contains(field), "missing {field}: {query}");
1708        }
1709        // Still reads the open PR off the Ref (not the Commit) and reuses the rollup.
1710        assert!(
1711            query.contains("associatedPullRequests(first:1, states:OPEN)"),
1712            "{query}"
1713        );
1714        assert!(query.contains("checkSuite{ status }"), "{query}");
1715        // No `rateLimit` fold — this is not the hot-path poll.
1716        assert!(!query.contains("rateLimit"), "{query}");
1717        assert_eq!(index.len(), 1);
1718    }
1719
1720    #[test]
1721    fn merge_query_is_none_without_targets() {
1722        assert!(build_merge_query(&[]).is_none());
1723    }
1724
1725    #[test]
1726    fn parse_merge_response_reads_pr_facts_and_already_queued() {
1727        let (_, index) = build_merge_query(&[target("ready"), target("queued")]).unwrap();
1728        // Aliases: BTreeMap by (owner,name) then input order → b0=ready, b1=queued.
1729        let body = json!({"data":{"r0":{
1730            "b0":{
1731                "target":{"oid":"sha-ready","statusCheckRollup":{"contexts":{"nodes":[
1732                    {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1733                ]}}},
1734                "associatedPullRequests":{"nodes":[{
1735                    "id":"PR_ready","number":10,"isDraft":false,
1736                    "url":"https://github.com/rust-works/omni-dev/pull/10",
1737                    "headRefOid":"sha-ready","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1738                }]}
1739            },
1740            "b1":{
1741                "target":{"oid":"sha-q","statusCheckRollup":null},
1742                "associatedPullRequests":{"nodes":[{
1743                    "id":"PR_queued","number":11,"isDraft":true,
1744                    "url":"https://github.com/rust-works/omni-dev/pull/11",
1745                    "headRefOid":"sha-q","mergeStateStatus":"BLOCKED",
1746                    "mergeQueueEntry":{"state":"QUEUED"}
1747                }]}
1748            }
1749        }}});
1750        let out = parse_merge_response(&body, &index);
1751
1752        let ready = out.get(&target("ready")).expect("ready resolved");
1753        assert_eq!(ready.pr_id, "PR_ready");
1754        assert_eq!(ready.number, 10);
1755        assert_eq!(ready.head_oid, "sha-ready");
1756        assert_eq!(ready.merge_state.as_deref(), Some("CLEAN"));
1757        assert!(!ready.is_draft);
1758        assert!(!ready.already_queued);
1759        assert_eq!(ready.checks, PrCheckState::Success);
1760
1761        let queued = out.get(&target("queued")).expect("queued resolved");
1762        assert!(queued.is_draft);
1763        assert!(queued.already_queued);
1764        assert_eq!(queued.merge_state.as_deref(), Some("BLOCKED"));
1765        // No rollup nodes → no checks reported.
1766        assert_eq!(queued.checks, PrCheckState::None);
1767    }
1768
1769    #[test]
1770    fn parse_merge_response_treats_absent_or_prless_refs_as_skips() {
1771        let (_, index) = build_merge_query(&[target("null-ref"), target("no-pr")]).unwrap();
1772        let body = json!({"data":{"r0":{
1773            // A never-pushed branch: the aliased ref is null.
1774            "b0": null,
1775            // Pushed, but no open PR heads it.
1776            "b1": {
1777                "target":{"oid":"x","statusCheckRollup":null},
1778                "associatedPullRequests":{"nodes":[]}
1779            }
1780        }}});
1781        let out = parse_merge_response(&body, &index);
1782        // Neither is enqueue-eligible, so neither appears — the caller maps the
1783        // absence to a `no-pr` skip.
1784        assert!(out.is_empty(), "{out:?}");
1785    }
1786
1787    #[test]
1788    fn merge_info_requires_an_id_and_number() {
1789        // A PR node missing `id` (a malformed/partial reply) is unresolved, never a
1790        // half-built MergeInfo we might try to enqueue.
1791        let node = json!({
1792            "target":{"oid":"x","statusCheckRollup":null},
1793            "associatedPullRequests":{"nodes":[{"number":9,"url":"u"}]}
1794        });
1795        assert!(merge_info_from_ref(&node).is_none());
1796    }
1797
1798    #[test]
1799    fn enqueue_error_message_joins_or_falls_back() {
1800        let errs = vec![
1801            json!({"message":"Merge queue is not enabled"}),
1802            json!({"message":"Pull request is not mergeable"}),
1803        ];
1804        assert_eq!(
1805            enqueue_error_message(&errs),
1806            "Merge queue is not enabled; Pull request is not mergeable"
1807        );
1808        // No readable messages → a generic-but-non-empty reason.
1809        assert_eq!(
1810            enqueue_error_message(&[json!({"code":"X"})]),
1811            "enqueue rejected by GitHub"
1812        );
1813    }
1814
1815    #[test]
1816    fn parse_merge_response_tolerates_a_missing_data_block() {
1817        // A reply with no `data` (e.g. a top-level error already handled upstream)
1818        // yields no targets rather than panicking.
1819        let (_, index) = build_merge_query(&[target("main")]).unwrap();
1820        assert!(parse_merge_response(&json!({}), &index).is_empty());
1821    }
1822
1823    #[test]
1824    fn resolve_merge_targets_asks_nothing_for_no_targets() {
1825        // No branches → no subprocess at all; the bogus binary would error if spawned.
1826        let out = resolve_merge_targets(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1827        assert!(out.is_empty());
1828    }
1829
1830    #[test]
1831    fn resolve_merge_targets_reads_a_reply_end_to_end() {
1832        let dir = tempfile::tempdir().unwrap();
1833        let (bin, _shim) = fake_gh(
1834            dir.path(),
1835            r#"{"data":{"r0":{"b0":{
1836                "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1837                  {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1838                ]}}},
1839                "associatedPullRequests":{"nodes":[{
1840                  "id":"PR_1","number":42,"isDraft":false,"url":"u42",
1841                  "headRefOid":"a","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1842                }]}}}}}"#,
1843            0,
1844        );
1845        let out = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap();
1846        let info = out.get(&target("main")).expect("resolved");
1847        assert_eq!(info.pr_id, "PR_1");
1848        assert_eq!(info.number, 42);
1849        assert_eq!(info.head_oid, "a");
1850        assert_eq!(info.merge_state.as_deref(), Some("CLEAN"));
1851        assert!(!info.already_queued);
1852        assert_eq!(info.checks, PrCheckState::Success);
1853    }
1854
1855    #[test]
1856    fn resolve_merge_targets_errors_on_a_graphql_errors_body() {
1857        let dir = tempfile::tempdir().unwrap();
1858        let (bin, _shim) = fake_gh(
1859            dir.path(),
1860            r#"{"errors":[{"message":"Something is wrong"}]}"#,
1861            0,
1862        );
1863        let err = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap_err();
1864        assert!(format!("{err:#}").contains("returned errors"), "{err:#}");
1865    }
1866
1867    #[test]
1868    fn enqueue_pull_request_reports_queued_on_success() {
1869        let dir = tempfile::tempdir().unwrap();
1870        let (bin, _shim) = fake_gh(
1871            dir.path(),
1872            r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
1873            0,
1874        );
1875        let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1876        assert_eq!(out, EnqueueOutcome::Queued(Some("QUEUED".to_string())));
1877    }
1878
1879    #[test]
1880    fn enqueue_pull_request_maps_a_nonzero_exit_to_rejected() {
1881        // `gh` exits non-zero on a rejected mutation; the reason is read from the
1882        // JSON `errors` body (preferred over stderr).
1883        let dir = tempfile::tempdir().unwrap();
1884        let (bin, _shim) = fake_gh(
1885            dir.path(),
1886            r#"{"errors":[{"message":"Merge queue is not enabled on this repository"}]}"#,
1887            1,
1888        );
1889        let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1890        assert_eq!(
1891            out,
1892            EnqueueOutcome::Rejected("Merge queue is not enabled on this repository".to_string())
1893        );
1894    }
1895
1896    #[test]
1897    fn enqueue_pull_request_maps_a_200_with_errors_to_rejected() {
1898        // A GraphQL 200 can still carry errors (a partial success); treat it as a
1899        // per-PR rejection, not a batch-sinking error.
1900        let dir = tempfile::tempdir().unwrap();
1901        let (bin, _shim) = fake_gh(
1902            dir.path(),
1903            r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
1904            0,
1905        );
1906        let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1907        assert_eq!(
1908            out,
1909            EnqueueOutcome::Rejected("Pull request is not mergeable".to_string())
1910        );
1911    }
1912}