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