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