Skip to main content

omni_dev/
github_issues.rs

1//! GitHub issue fetching for `ai jev route` (#1779).
2//!
3//! Mirrors [`crate::pr_status`]'s shape: shell out to `gh` (ADR-0003 — the
4//! GitHub token never enters our process), build one aliased `gh api
5//! graphql` query per batch of issues grouped by repository (free aliasing —
6//! `repository(owner:,name:)` and `issue(number:)` take no `first:`/`last:`,
7//! so neither is a connection and neither contributes to the query's point
8//! cost — see `pr_status`'s module docs for the measured numbers), and reuse
9//! `crate::pr_status::run_gh_graphql` rather than writing a second
10//! subprocess wrapper.
11//!
12//! Every call funnels through [`crate::github_metrics::run_gh`] (or
13//! `run_gh_graphql`, which already does), so it is counted and logged like
14//! every other `gh` invocation (#1387).
15
16use std::collections::{BTreeMap, HashMap, HashSet};
17use std::path::Path;
18
19use anyhow::{anyhow, bail, Context, Result};
20use chrono::{DateTime, Utc};
21use serde_json::Value;
22use tracing::{debug, warn};
23
24use crate::provider::{Comment, GitProvider, IssueDoc, ItemKind, ItemRef, ItemState};
25use crate::utils::env::EnvSource;
26
27mod cache;
28
29pub use cache::{CacheUsage, IssueCache, DEFAULT_TTL as DEFAULT_CACHE_TTL, GITHUB_CACHE_TTL_ENV};
30
31/// Most issues resolved by one `gh api graphql` call. Each issue carries up
32/// to [`MAX_COMMENTS`] comment bodies, so an unbounded `--all-open` query on a
33/// large backlog would hit GraphQL's node and response-size limits.
34const MAX_ISSUES_PER_QUERY: usize = 25;
35
36/// Most comments fetched per issue: the **latest** ones, since a decision
37/// comment lands late and is what moves routing most. An issue with more is
38/// warned about, not paginated: the routed input is truncated well before
39/// this many comments would fit anyway.
40const MAX_COMMENTS: usize = 100;
41
42/// The login GitHub reports for a deleted account.
43const GHOST_LOGIN: &str = "ghost";
44
45/// Whether `raw` names an issue only by number (`#N` or `N`), so it needs the
46/// current repository to resolve. Lets callers skip the `gh repo view`
47/// round-trip when every argument is already qualified.
48#[must_use]
49pub fn needs_default_project(raw: &str) -> bool {
50    let raw = raw.trim();
51    let digits = raw.strip_prefix('#').unwrap_or(raw);
52    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
53}
54
55/// Parses one `<ISSUE>` CLI argument into an [`ItemRef`].
56///
57/// Accepts, in order:
58/// - a full GitHub issue URL: `https://github.com/OWNER/REPO/issues/N`
59/// - `owner/repo#N`
60/// - `#N` or bare `N`, resolved against `default_project` (the `-C/--repo`
61///   checkout's own `owner/repo`) — an error naming the missing context if
62///   `default_project` is `None`.
63pub fn parse_issue_arg(raw: &str, default_project: Option<&str>) -> Result<ItemRef> {
64    let raw = raw.trim();
65
66    if let Some(rest) = raw
67        .strip_prefix("https://github.com/")
68        .or_else(|| raw.strip_prefix("http://github.com/"))
69    {
70        let mut parts = rest.splitn(4, '/');
71        let owner = parts.next().filter(|s| !s.is_empty());
72        let repo = parts.next().filter(|s| !s.is_empty());
73        let segment = parts.next();
74        let number_part = parts.next();
75        if let (Some(owner), Some(repo), Some("issues"), Some(number_part)) =
76            (owner, repo, segment, number_part)
77        {
78            let digits: String = number_part
79                .chars()
80                .take_while(char::is_ascii_digit)
81                .collect();
82            let number = digits
83                .parse::<u64>()
84                .map_err(|_| anyhow!("`{raw}` does not end in a valid issue number"))?;
85            return Ok(ItemRef {
86                provider: GitProvider::GitHub,
87                project: format!("{owner}/{repo}"),
88                kind: ItemKind::Issue,
89                number,
90            });
91        }
92        bail!(
93            "`{raw}` is not a recognised GitHub issue URL \
94             (expected https://github.com/OWNER/REPO/issues/N)"
95        );
96    }
97
98    if let Some((project, number_part)) = raw.split_once('#') {
99        if project.contains('/') {
100            if !is_owner_repo(project) {
101                bail!("`{raw}` does not name a repository as owner/repo");
102            }
103            let number = number_part
104                .parse::<u64>()
105                .with_context(|| format!("`{raw}` does not have a valid issue number"))?;
106            return Ok(ItemRef {
107                provider: GitProvider::GitHub,
108                project: project.to_string(),
109                kind: ItemKind::Issue,
110                number,
111            });
112        }
113    }
114
115    let digits = raw.strip_prefix('#').unwrap_or(raw);
116    if let Ok(number) = digits.parse::<u64>() {
117        let project = default_project.ok_or_else(|| {
118            anyhow!(
119                "`{raw}` has no repository; pass owner/repo#{digits}, a full issue URL, \
120                 or run inside a GitHub repository checkout (see -C/--repo)"
121            )
122        })?;
123        return Ok(ItemRef {
124            provider: GitProvider::GitHub,
125            project: project.to_string(),
126            kind: ItemKind::Issue,
127            number,
128        });
129    }
130
131    bail!(
132        "`{raw}` is not a recognised issue reference \
133         (expected #N, N, owner/repo#N, or a GitHub issue URL)"
134    );
135}
136
137/// Whether `project` is exactly `owner/repo`: two non-empty segments with no
138/// whitespace. GitHub has no nested paths, so `a/b/c` would otherwise reach
139/// the query as a repository literally named `b/c`.
140fn is_owner_repo(project: &str) -> bool {
141    let segment_ok =
142        |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(char::is_whitespace);
143    project
144        .split_once('/')
145        .is_some_and(|(owner, name)| segment_ok(owner) && segment_ok(name))
146}
147
148/// Resolves the `owner/repo` of the GitHub repository checked out at `cwd`,
149/// via `gh repo view`.
150pub fn resolve_current_project(bin: &Path, cwd: &Path) -> Result<String> {
151    let output = crate::github_metrics::run_gh(
152        bin,
153        [
154            "repo",
155            "view",
156            "--json",
157            "nameWithOwner",
158            "--jq",
159            ".nameWithOwner",
160        ],
161        "repo view",
162        Some(cwd),
163    )
164    .with_context(|| {
165        format!(
166            "Failed to run {} (is the GitHub CLI installed?)",
167            bin.display()
168        )
169    })?;
170    if !output.status.success() {
171        let stderr = String::from_utf8_lossy(&output.stderr);
172        bail!("gh repo view failed: {}", stderr.trim());
173    }
174    let project = String::from_utf8_lossy(&output.stdout).trim().to_string();
175    if project.is_empty() {
176        bail!("gh repo view returned an empty repository name");
177    }
178    Ok(project)
179}
180
181/// Most issues `--all-open` lists; reaching it is warned about.
182const MAX_OPEN_ISSUES: usize = 1000;
183
184/// Lists every open issue number in `project`, for `--all-open`.
185pub fn list_open_issue_numbers(bin: &Path, project: &str) -> Result<Vec<u64>> {
186    let limit = MAX_OPEN_ISSUES.to_string();
187    let output = crate::github_metrics::run_gh(
188        bin,
189        [
190            "issue", "list", "--state", "open", "--json", "number", "-R", project, "--limit",
191            &limit,
192        ],
193        "issue list",
194        None,
195    )
196    .with_context(|| {
197        format!(
198            "Failed to run {} (is the GitHub CLI installed?)",
199            bin.display()
200        )
201    })?;
202    if !output.status.success() {
203        let stderr = String::from_utf8_lossy(&output.stderr);
204        bail!("gh issue list failed: {}", stderr.trim());
205    }
206    let value: Value =
207        serde_json::from_slice(&output.stdout).context("gh issue list returned invalid JSON")?;
208    let numbers: Vec<u64> = value
209        .as_array()
210        .context("gh issue list returned non-array JSON")?
211        .iter()
212        .filter_map(|v| v.get("number").and_then(Value::as_u64))
213        .collect();
214    if numbers.len() >= MAX_OPEN_ISSUES {
215        warn!("{project} has at least {MAX_OPEN_ISSUES} open issues; only the first {MAX_OPEN_ISSUES} are listed");
216    }
217    Ok(numbers)
218}
219
220/// Maps a query's `(repo alias index, issue alias index)` back to the
221/// [`ItemRef`] it was built for.
222type QueryIndex = HashMap<(usize, usize), ItemRef>;
223
224/// Builds the single aliased query for every ref, grouped by project so each
225/// repository appears once — same shape as
226/// `pr_status::build_query`/`branch_fragment`. Returns `None` for an empty
227/// slice.
228fn build_issue_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
229    if refs.is_empty() {
230        return None;
231    }
232    let mut by_project: BTreeMap<&str, Vec<&ItemRef>> = BTreeMap::new();
233    for item_ref in refs {
234        by_project
235            .entry(item_ref.project.as_str())
236            .or_default()
237            .push(item_ref);
238    }
239    let mut index = HashMap::new();
240    let mut repos = Vec::new();
241    for (ri, (project, items)) in by_project.iter().enumerate() {
242        let Some((owner, name)) = project.split_once('/') else {
243            continue; // validated by parse_issue_arg; defensive skip only
244        };
245        let mut frags = Vec::new();
246        for (ii, item_ref) in items.iter().enumerate() {
247            frags.push(issue_fragment(&format!("i{ii}"), item_ref.number));
248            index.insert((ri, ii), (*item_ref).clone());
249        }
250        let owner = Value::String(owner.to_string());
251        let name = Value::String(name.to_string());
252        repos.push(format!(
253            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
254            frags.join("\n")
255        ));
256    }
257    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
258}
259
260/// The GraphQL fragment resolving one issue: title, body, state, url, its
261/// latest comments (still returned oldest first, so a routed input reads in
262/// conversation order),
263/// and the numbers of the change requests that closed it.
264/// `closedByPullRequestsReferences` asks only for `number` — the closing PRs'
265/// own title/body are out of scope for `route`'s input (the issue explicitly
266/// says not to fold referenced items into the routed text); a later
267/// `verify-decision` cited-source fetch re-queries them.
268fn issue_fragment(alias: &str, number: u64) -> String {
269    format!(
270        r"{alias}: issue(number:{number}){{
271      title body state url
272      comments(last:{MAX_COMMENTS}){{ totalCount nodes{{ databaseId author{{ __typename login }} body }} }}
273      closedByPullRequestsReferences(first:10){{ nodes{{ number }} }}
274    }}"
275    )
276}
277
278/// Whether a comment author is a bot. GraphQL reports a GitHub App author as
279/// `__typename: "Bot"` with the `[bot]` suffix *stripped* from its login
280/// (`github-actions`, not `github-actions[bot]`), so the type is the reliable
281/// signal; the suffix check covers anything that still carries it.
282fn is_bot_author(author: &Value) -> bool {
283    author.get("__typename").and_then(Value::as_str) == Some("Bot")
284        || author
285            .get("login")
286            .and_then(Value::as_str)
287            .is_some_and(|login| login.ends_with("[bot]"))
288}
289
290/// Reads the human comments out of an issue node, warning when the issue has
291/// more than [`MAX_COMMENTS`]. A comment whose author was deleted (`author:
292/// null`) is kept under [`GHOST_LOGIN`], as GitHub's own UI shows it — the
293/// text is still human judgement.
294fn human_comments(item_ref: &ItemRef, node: &Value) -> Vec<Comment> {
295    let comments = node.get("comments");
296    let total = comments
297        .and_then(|c| c.get("totalCount"))
298        .and_then(Value::as_u64)
299        .unwrap_or_default();
300    if total > MAX_COMMENTS as u64 {
301        warn!("Issue {item_ref} has {total} comments; only the latest {MAX_COMMENTS} are used");
302    }
303    comments
304        .and_then(|c| c.get("nodes"))
305        .and_then(Value::as_array)
306        .into_iter()
307        .flatten()
308        .filter_map(|c| {
309            let author = c.get("author").filter(|a| !a.is_null());
310            if author.is_some_and(is_bot_author) {
311                return None;
312            }
313            let login = author
314                .and_then(|a| a.get("login"))
315                .and_then(Value::as_str)
316                .unwrap_or(GHOST_LOGIN);
317            let body = c.get("body")?.as_str()?.to_string();
318            let id = c.get("databaseId").and_then(Value::as_u64);
319            Some(Comment {
320                author: login.to_string(),
321                body,
322                id,
323            })
324        })
325        .collect()
326}
327
328/// Builds one [`IssueDoc`] from its GraphQL node.
329fn build_issue_doc(item_ref: &ItemRef, node: &Value) -> Result<IssueDoc> {
330    let title = node
331        .get("title")
332        .and_then(Value::as_str)
333        .with_context(|| format!("issue {item_ref}: response had no `title`"))?
334        .to_string();
335    let body = node
336        .get("body")
337        .and_then(Value::as_str)
338        .unwrap_or_default()
339        .to_string();
340    let url = node
341        .get("url")
342        .and_then(Value::as_str)
343        .with_context(|| format!("issue {item_ref}: response had no `url`"))?
344        .to_string();
345    let state = match node.get("state").and_then(Value::as_str) {
346        Some("OPEN") => ItemState::Open,
347        Some("CLOSED") => ItemState::Closed,
348        other => bail!("issue {item_ref}: unrecognised state {other:?}"),
349    };
350
351    let comments = human_comments(item_ref, node);
352
353    let closed_by = node
354        .get("closedByPullRequestsReferences")
355        .and_then(|c| c.get("nodes"))
356        .and_then(Value::as_array)
357        .into_iter()
358        .flatten()
359        .filter_map(|pr| {
360            let number = pr.get("number")?.as_u64()?;
361            Some(ItemRef {
362                provider: GitProvider::GitHub,
363                project: item_ref.project.clone(),
364                kind: ItemKind::ChangeRequest,
365                number,
366            })
367        })
368        .collect();
369
370    Ok(IssueDoc {
371        provider: GitProvider::GitHub,
372        project: item_ref.project.clone(),
373        number: item_ref.number,
374        kind: ItemKind::Issue,
375        title,
376        state,
377        body,
378        comments,
379        closed_by,
380        url,
381    })
382}
383
384/// Reads every aliased issue out of a query reply, keyed by `(project,
385/// number)`. Errors, naming the issue, if any aliased node is missing or
386/// null — a fetched ref that doesn't resolve is a hard input error here,
387/// unlike `pr_status`'s tri-state design (there, "no PR" is a valid
388/// successful answer; here, "no such issue" never is).
389fn parse_issue_response(
390    body: &Value,
391    index: &QueryIndex,
392) -> Result<HashMap<(String, u64), IssueDoc>> {
393    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
394        if !errors.is_empty() {
395            bail!("{}", describe_graphql_errors(errors, index));
396        }
397    }
398    let data = body
399        .get("data")
400        .context("gh api graphql response had no `data`")?;
401
402    let mut docs = HashMap::with_capacity(index.len());
403    for ((ri, ii), item_ref) in index {
404        let node = data
405            .get(format!("r{ri}"))
406            .and_then(|r| r.get(format!("i{ii}")))
407            .filter(|v| !v.is_null());
408        let Some(node) = node else {
409            bail!("issue {item_ref} was not found (check the number and that gh has access)");
410        };
411        docs.insert(
412            (item_ref.project.clone(), item_ref.number),
413            build_issue_doc(item_ref, node)?,
414        );
415    }
416    Ok(docs)
417}
418
419/// Turns a GraphQL `errors` array into one message. A `NOT_FOUND` error is
420/// mapped back through its `path` (`["r0", "i1"]`) to the issue or
421/// repository it names — GitHub reports a missing issue, or a pull-request
422/// number asked for as an issue, this way rather than as a `null` node alone.
423/// Anything else is reported by its `message`.
424fn describe_graphql_errors(errors: &[Value], index: &QueryIndex) -> String {
425    let alias = |segment: Option<&Value>, prefix: char| {
426        segment
427            .and_then(Value::as_str)
428            .and_then(|s| s.strip_prefix(prefix))
429            .and_then(|n| n.parse::<usize>().ok())
430    };
431    let described: Vec<String> = errors
432        .iter()
433        .map(|error| {
434            let path = error.get("path").and_then(Value::as_array);
435            let repo = alias(path.and_then(|p| p.first()), 'r');
436            let issue = alias(path.and_then(|p| p.get(1)), 'i');
437            let not_found = error.get("type").and_then(Value::as_str) == Some("NOT_FOUND");
438            match (not_found, repo, issue) {
439                (true, Some(ri), Some(ii)) if index.contains_key(&(ri, ii)) => format!(
440                    "issue {} was not found (check the number, that it is an issue rather \
441                     than a pull request, and that gh has access)",
442                    index[&(ri, ii)]
443                ),
444                (true, Some(ri), None) => {
445                    let project = index
446                        .iter()
447                        .find(|((r, _), _)| *r == ri)
448                        .map_or("?", |(_, item_ref)| item_ref.project.as_str());
449                    format!("repository {project} was not found (or gh has no access to it)")
450                }
451                _ => error
452                    .get("message")
453                    .and_then(Value::as_str)
454                    .map_or_else(|| error.to_string(), str::to_string),
455            }
456        })
457        .collect();
458    format!("gh api graphql failed: {}", described.join("; "))
459}
460
461/// Fetches every ref, returning issues in the same order as `refs`.
462///
463/// Makes one `gh api graphql` call per `MAX_ISSUES_PER_QUERY` refs, each
464/// grouping its refs by repo alias. **Blocking** — callers must be on a
465/// blocking thread.
466pub fn fetch_issues(bin: &Path, refs: &[ItemRef]) -> Result<Vec<IssueDoc>> {
467    let mut by_key = HashMap::with_capacity(refs.len());
468    for chunk in refs.chunks(MAX_ISSUES_PER_QUERY) {
469        let Some((query, index)) = build_issue_query(chunk) else {
470            continue;
471        };
472        let body = crate::pr_status::run_gh_graphql(bin, &query)?;
473        by_key.extend(parse_issue_response(&body, &index)?);
474    }
475    refs.iter()
476        .map(|item_ref| {
477            by_key
478                .get(&(item_ref.project.clone(), item_ref.number))
479                .cloned()
480                .ok_or_else(|| anyhow!("issue {item_ref} missing from the parsed gh reply (bug)"))
481        })
482        .collect()
483}
484
485/// The GraphQL fragment resolving one reference that may be either an issue
486/// or a pull request — used by `verify-decision` to fetch a decision
487/// comment's cited sources, where `#N` is ambiguous until the API answers.
488/// `__typename` decides the [`ItemKind`]; only [`ItemKind::Issue`] carries
489/// comments and closing PRs (a pull request's own body is the whole source).
490fn item_fragment(alias: &str, number: u64) -> String {
491    format!(
492        r"{alias}: issueOrPullRequest(number:{number}){{
493      __typename
494      ... on Issue {{
495        title body state url
496        comments(last:{MAX_COMMENTS}){{ totalCount nodes{{ databaseId author{{ __typename login }} body }} }}
497        closedByPullRequestsReferences(first:10){{ nodes{{ number }} }}
498      }}
499      ... on PullRequest {{
500        title body state url
501      }}
502    }}"
503    )
504}
505
506/// [`build_issue_query`], but for [`item_fragment`] — same repo-grouping and
507/// alias scheme, so [`parse_item_response`] can reuse [`describe_graphql_errors`].
508fn build_item_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
509    build_aliased_query(refs, item_fragment)
510}
511
512/// The GraphQL fragment resolving only an issue's or pull request's `state`,
513/// under the same `issueOrPullRequest` field as [`item_fragment`].
514fn state_fragment(alias: &str, number: u64) -> String {
515    format!(
516        r"{alias}: issueOrPullRequest(number:{number}){{
517      __typename
518      ... on Issue {{ state }}
519      ... on PullRequest {{ state }}
520    }}"
521    )
522}
523
524/// [`build_item_query`] for [`state_fragment`].
525fn build_state_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
526    build_aliased_query(refs, state_fragment)
527}
528
529/// Minimal timestamp selection for either item type. The aliases key replies.
530fn updated_at_fragment(alias: &str, number: u64) -> String {
531    format!(
532        r"{alias}: issueOrPullRequest(number:{number}){{
533      ... on Issue {{ updatedAt }}
534      ... on PullRequest {{ updatedAt }}
535    }}"
536    )
537}
538
539fn build_updated_at_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
540    build_aliased_query(refs, updated_at_fragment)
541}
542
543fn parse_updated_at_response(
544    body: &Value,
545    index: &QueryIndex,
546) -> Result<Parsed<Option<DateTime<Utc>>>> {
547    parse_aliased_response(body, index, |item_ref, node| {
548        let timestamp = node
549            .get("updatedAt")
550            .and_then(Value::as_str)
551            .with_context(|| format!("item {item_ref}: response had no string `updatedAt`"))?;
552        DateTime::parse_from_rfc3339(timestamp)
553            .map(|timestamp| timestamp.with_timezone(&Utc))
554            .with_context(|| format!("item {item_ref}: invalid `updatedAt` {timestamp:?}"))
555    })
556}
557
558/// Builds the single aliased query for every ref, grouped by project so each
559/// repository appears once, resolving each ref with `fragment`. Returns `None`
560/// for an empty slice.
561fn build_aliased_query(
562    refs: &[ItemRef],
563    fragment: fn(&str, u64) -> String,
564) -> Option<(String, QueryIndex)> {
565    if refs.is_empty() {
566        return None;
567    }
568    let mut by_project: BTreeMap<&str, Vec<&ItemRef>> = BTreeMap::new();
569    for item_ref in refs {
570        by_project
571            .entry(item_ref.project.as_str())
572            .or_default()
573            .push(item_ref);
574    }
575    let mut index = HashMap::new();
576    let mut repos = Vec::new();
577    for (ri, (project, items)) in by_project.iter().enumerate() {
578        let Some((owner, name)) = project.split_once('/') else {
579            // omni-dev: coverage ignore-line reason="validated by the citation parser; every ItemRef reaching here already has a project of the form owner/repo"
580            continue;
581        };
582        let mut frags = Vec::new();
583        for (ii, item_ref) in items.iter().enumerate() {
584            frags.push(fragment(&format!("i{ii}"), item_ref.number));
585            index.insert((ri, ii), (*item_ref).clone());
586        }
587        let owner = Value::String(owner.to_string());
588        let name = Value::String(name.to_string());
589        repos.push(format!(
590            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
591            frags.join("\n")
592        ));
593    }
594    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
595}
596
597/// Reads an `issueOrPullRequest` node's `state`. A merged pull request is
598/// closed, as far as `route` is concerned.
599fn parse_item_state(item_ref: &ItemRef, node: &Value) -> Result<ItemState> {
600    match node.get("state").and_then(Value::as_str) {
601        Some("OPEN") => Ok(ItemState::Open),
602        Some("CLOSED" | "MERGED") => Ok(ItemState::Closed),
603        other => bail!("item {item_ref}: unrecognised state {other:?}"),
604    }
605}
606
607/// Builds an [`IssueDoc`] from an `issueOrPullRequest` node, using
608/// `__typename` — not the caller's `item_ref.kind` hint — to decide whether
609/// this is an issue or a change request: a decision comment citing "PR #N"
610/// when `#N` is actually an issue (or vice versa) must be resolved by what
611/// the item *is*, not by how it was written.
612fn build_item_doc(item_ref: &ItemRef, node: &Value) -> Result<IssueDoc> {
613    let kind = match node.get("__typename").and_then(Value::as_str) {
614        Some("Issue") => ItemKind::Issue,
615        Some("PullRequest") => ItemKind::ChangeRequest,
616        other => bail!("item {item_ref}: unrecognised __typename {other:?}"),
617    };
618    let title = node
619        .get("title")
620        .and_then(Value::as_str)
621        .with_context(|| format!("item {item_ref}: response had no `title`"))?
622        .to_string();
623    let body = node
624        .get("body")
625        .and_then(Value::as_str)
626        .unwrap_or_default()
627        .to_string();
628    let url = node
629        .get("url")
630        .and_then(Value::as_str)
631        .with_context(|| format!("item {item_ref}: response had no `url`"))?
632        .to_string();
633    let state = parse_item_state(item_ref, node)?;
634    let (comments, closed_by) = if kind == ItemKind::Issue {
635        (
636            human_comments(item_ref, node),
637            node.get("closedByPullRequestsReferences")
638                .and_then(|c| c.get("nodes"))
639                .and_then(Value::as_array)
640                .into_iter()
641                .flatten()
642                .filter_map(|pr| {
643                    let number = pr.get("number")?.as_u64()?;
644                    Some(ItemRef {
645                        provider: GitProvider::GitHub,
646                        project: item_ref.project.clone(),
647                        kind: ItemKind::ChangeRequest,
648                        number,
649                    })
650                })
651                .collect(),
652        )
653    } else {
654        (Vec::new(), Vec::new())
655    };
656
657    Ok(IssueDoc {
658        provider: GitProvider::GitHub,
659        project: item_ref.project.clone(),
660        number: item_ref.number,
661        kind,
662        title,
663        state,
664        body,
665        comments,
666        closed_by,
667        url,
668    })
669}
670
671/// The `(missing items, missing repositories)` of [`tolerated_not_found`].
672type NotFoundSlots = (HashSet<(usize, usize)>, HashSet<usize>);
673
674/// The aliased slots a reply reports as `NOT_FOUND`: `(repo, item)` pairs for
675/// a missing item (path `["rK", "iN"]`) and repo indexes for a missing or
676/// inaccessible repository (path `["rK"]`), in that order. Any other GraphQL error (a rate
677/// limit, a `NOT_FOUND` on a path the query didn't alias) fails the batch —
678/// retrying it per item would not help.
679fn tolerated_not_found(body: &Value, index: &QueryIndex) -> Result<NotFoundSlots> {
680    let mut not_found = HashSet::new();
681    let mut missing_repos = HashSet::new();
682    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
683        for error in errors {
684            let is_not_found = error.get("type").and_then(Value::as_str) == Some("NOT_FOUND");
685            let path = error.get("path").and_then(Value::as_array);
686            let alias = |segment: Option<&Value>, prefix: char| {
687                segment
688                    .and_then(Value::as_str)
689                    .and_then(|s| s.strip_prefix(prefix))
690                    .and_then(|n| n.parse::<usize>().ok())
691            };
692            let ri = alias(path.and_then(|p| p.first()), 'r');
693            let ii = alias(path.and_then(|p| p.get(1)), 'i');
694            match (is_not_found, ri, ii) {
695                (true, Some(ri), Some(ii)) if index.contains_key(&(ri, ii)) => {
696                    not_found.insert((ri, ii));
697                }
698                (true, Some(ri), None) if index.keys().any(|(r, _)| *r == ri) => {
699                    missing_repos.insert(ri);
700                }
701                _ => bail!(
702                    "{}",
703                    describe_graphql_errors(std::slice::from_ref(error), index)
704                ),
705            }
706        }
707    }
708    Ok((not_found, missing_repos))
709}
710
711/// Reads every aliased item out of an [`item_fragment`] query reply.
712///
713/// Unlike [`parse_issue_response`], a `NOT_FOUND` is **tolerated** as
714/// `None` rather than failing the whole batch: a citation can be stale, a
715/// typo, or a quoted example (`owner/repo#123`), and one bad citation must
716/// not stop `route` or `verify-decision` from handling the rest. That covers
717/// both a missing item and a missing or inaccessible repository, which
718/// resolves every item cited in it to `None` (#2001); see
719/// [`tolerated_not_found`].
720fn parse_item_response(
721    body: &Value,
722    index: &QueryIndex,
723) -> Result<HashMap<(String, u64), Option<IssueDoc>>> {
724    parse_aliased_response(body, index, build_item_doc)
725}
726
727/// [`parse_item_response`] for a [`state_fragment`] reply: `None` for an item
728/// GitHub can't find.
729fn parse_state_response(
730    body: &Value,
731    index: &QueryIndex,
732) -> Result<HashMap<(String, u64), Option<ItemState>>> {
733    parse_aliased_response(body, index, parse_item_state)
734}
735
736/// Reads each aliased node out of a reply with `build`, mapping a not-found
737/// slot to `None`.
738fn parse_aliased_response<T>(
739    body: &Value,
740    index: &QueryIndex,
741    build: impl Fn(&ItemRef, &Value) -> Result<T>,
742) -> Result<HashMap<(String, u64), Option<T>>> {
743    let (not_found, missing_repos) = tolerated_not_found(body, index)?;
744    let data = body
745        .get("data")
746        .context("gh api graphql response had no `data`")?;
747
748    let mut found = HashMap::with_capacity(index.len());
749    for ((ri, ii), item_ref) in index {
750        let key = (item_ref.project.clone(), item_ref.number);
751        if missing_repos.contains(ri) || not_found.contains(&(*ri, *ii)) {
752            found.insert(key, None);
753            continue;
754        }
755        let node = data
756            .get(format!("r{ri}"))
757            .and_then(|r| r.get(format!("i{ii}")))
758            .filter(|v| !v.is_null());
759        match node {
760            Some(node) => {
761                found.insert(key, Some(build(item_ref, node)?));
762            }
763            None => {
764                found.insert(key, None);
765            }
766        }
767    }
768    Ok(found)
769}
770
771/// Fetches a set of references that may be issues or pull requests.
772///
773/// Tolerates a per-reference "not found" (`None`) rather than failing the
774/// whole call, including a reference into a repository GitHub reports as
775/// `NOT_FOUND` — used by `route` and `verify-decision` to resolve citations.
776/// Any other error (e.g. an SSO-gated org's `FORBIDDEN`) still fails it. Returns one entry per `refs`, in the same order.
777/// **Blocking** — callers must be on a blocking thread.
778pub fn fetch_items(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<IssueDoc>>> {
779    fetch_aliased(bin, refs, build_item_query, parse_item_response)
780}
781
782/// Fetches only the `state` of each reference (an issue or a pull request),
783/// one entry per `refs` in the same order, `None` for one GitHub can't find.
784///
785/// The shallow recheck a cached copy's `state` is validated with (#2041): the
786/// same aliased batching as [`fetch_items`] but with no text, so it is the
787/// cheapest way to learn whether a cached item was closed or reopened since.
788/// **Blocking.**
789pub fn fetch_states(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<ItemState>>> {
790    fetch_aliased(bin, refs, build_state_query, parse_state_response)
791}
792
793/// Fetches only GitHub's `updatedAt` for each issue or pull request.
794///
795/// Returns one timestamp per ref in caller order (including duplicates), or
796/// `None` for an item or repository GitHub cannot find. Empty input makes no
797/// request. Uses the same repository aliases and bounded batches as [`fetch_items`].
798/// Other GraphQL, transport, and malformed timestamp errors fail the call.
799/// **Blocking** — callers must be on a blocking thread.
800///
801/// A caller can retain this timestamp alongside a fetched document, then
802/// compare for equality on a later check. Fetch full data with [`fetch_issues`]
803/// or [`fetch_items`] when the timestamp differs (in either direction) or no
804/// baseline exists. Neither `None` nor a failed check certifies a cached copy
805/// as fresh. The shallow and full requests are separate, not an atomic snapshot.
806/// This helper does not renew [`IssueCache`] entries itself.
807///
808/// This checks issue-level edits, including state, labels and new comments.
809/// Projects-v2 board/column changes live on `ProjectV2Item` and do **not** bump
810/// the issue's `updatedAt`: this check cannot establish board-state freshness.
811pub fn fetch_updated_at(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<DateTime<Utc>>>> {
812    fetch_aliased(bin, refs, build_updated_at_query, parse_updated_at_response)
813}
814
815/// One reply's parsed items, keyed by `(project, number)`.
816type Parsed<T> = HashMap<(String, u64), T>;
817
818/// Runs `refs` through `build` in chunks of [`MAX_ISSUES_PER_QUERY`], parses
819/// each reply with `parse`, and returns one entry per `refs`, in order.
820fn fetch_aliased<T: Clone>(
821    bin: &Path,
822    refs: &[ItemRef],
823    build: fn(&[ItemRef]) -> Option<(String, QueryIndex)>,
824    parse: fn(&Value, &QueryIndex) -> Result<Parsed<T>>,
825) -> Result<Vec<T>> {
826    let mut by_key = HashMap::with_capacity(refs.len());
827    for (query, index) in refs.chunks(MAX_ISSUES_PER_QUERY).filter_map(build) {
828        let body = crate::pr_status::run_gh_graphql_with_partial_data(bin, &query)?;
829        by_key.extend(parse(&body, &index)?);
830    }
831    refs.iter()
832        .map(|item_ref| {
833            by_key
834                .get(&(item_ref.project.clone(), item_ref.number))
835                .cloned()
836                .ok_or_else(|| anyhow!("item {item_ref} missing from the parsed gh reply (bug)"))
837        })
838        .collect()
839}
840
841/// [`fetch_issues`] through `cache`: serves fresh cached issues from disk,
842/// fetches only the rest, and caches what it fetched (#1858).
843///
844/// A cached pull request is not served, so asking for one as an issue still
845/// reaches GitHub and fails as it would uncached. **Blocking.**
846pub fn fetch_issues_cached(
847    bin: &Path,
848    cache: &IssueCache,
849    refs: &[ItemRef],
850) -> Result<Vec<IssueDoc>> {
851    fetch_issues_through(bin, cache, refs, |_| true)
852}
853
854/// [`fetch_issues_cached`], but a cached issue is served only if its `state`
855/// still matches GitHub's (#2041): a mismatch is a miss, fetched in full.
856///
857/// `state` decides what `route` does (skip, refuse, report a dependency), not
858/// just what it says, so it is not trusted for the whole TTL like the text.
859/// `listed` is the state the caller already knows every ref has — `--all-open`
860/// lists its issues as open — which needs no request; with `None`, the cached
861/// refs are rechecked with one shallow [`fetch_states`] call per chunk (none
862/// when nothing is cached). A failed recheck is not fatal: the cached copies
863/// are then treated as misses. **Blocking.**
864pub fn fetch_issues_cached_current(
865    bin: &Path,
866    cache: &IssueCache,
867    refs: &[ItemRef],
868    listed: Option<ItemState>,
869) -> Result<Vec<IssueDoc>> {
870    let check = StateCheck::new(bin, cache, refs, listed, |doc| doc.kind == ItemKind::Issue);
871    fetch_issues_through(bin, cache, refs, |doc| check.accepts(cache, doc))
872}
873
874fn fetch_issues_through(
875    bin: &Path,
876    cache: &IssueCache,
877    refs: &[ItemRef],
878    accept: impl Fn(&IssueDoc) -> bool,
879) -> Result<Vec<IssueDoc>> {
880    fetch_through_cache(
881        refs,
882        |item_ref| cache.lookup(item_ref, |doc| doc.kind == ItemKind::Issue && accept(doc)),
883        |misses| fetch_issues(bin, misses),
884        |doc| cache.store(doc),
885    )
886}
887
888/// [`fetch_issues`], always from GitHub, writing the result through to `cache`.
889///
890/// For an issue whose freshness matters to this run but whose copy is still
891/// useful to a later cached read. **Blocking.**
892pub fn fetch_issues_refreshed(
893    bin: &Path,
894    cache: &IssueCache,
895    refs: &[ItemRef],
896) -> Result<Vec<IssueDoc>> {
897    let docs = fetch_issues(bin, refs)?;
898    for doc in &docs {
899        cache.store(doc);
900    }
901    Ok(docs)
902}
903
904/// [`fetch_items`] through `cache`, like [`fetch_issues_cached`]. A
905/// not-found item is not cached, so it is re-queried next time. **Blocking.**
906pub fn fetch_items_cached(
907    bin: &Path,
908    cache: &IssueCache,
909    refs: &[ItemRef],
910) -> Result<Vec<Option<IssueDoc>>> {
911    fetch_items_through(bin, cache, refs, |_| true)
912}
913
914/// [`fetch_items_cached`] with the `state` check of
915/// [`fetch_issues_cached_current`]. **Blocking.**
916pub fn fetch_items_cached_current(
917    bin: &Path,
918    cache: &IssueCache,
919    refs: &[ItemRef],
920    listed: Option<ItemState>,
921) -> Result<Vec<Option<IssueDoc>>> {
922    let check = StateCheck::new(bin, cache, refs, listed, |_| true);
923    fetch_items_through(bin, cache, refs, |doc| check.accepts(cache, doc))
924}
925
926fn fetch_items_through(
927    bin: &Path,
928    cache: &IssueCache,
929    refs: &[ItemRef],
930    accept: impl Fn(&IssueDoc) -> bool,
931) -> Result<Vec<Option<IssueDoc>>> {
932    fetch_through_cache(
933        refs,
934        |item_ref| cache.lookup(item_ref, &accept).map(Some),
935        |misses| fetch_items(bin, misses),
936        |doc| {
937            if let Some(doc) = doc {
938                cache.store(doc);
939            }
940        },
941    )
942}
943
944/// How a cached doc's `state` is validated for one call (#2041).
945enum StateCheck {
946    /// The caller knows every ref's state (`--all-open`'s listing).
947    Listed(ItemState),
948    /// GitHub's current `state` for each cached ref that was rechecked, keyed
949    /// by `(lowercased project, number)`; `None` marks an item GitHub can't
950    /// find, or a recheck that failed.
951    Rechecked(HashMap<(String, u64), Option<ItemState>>),
952}
953
954impl StateCheck {
955    /// Checks the refs `cache` could serve (and `kind` accepts) against
956    /// `listed`, or against one shallow [`fetch_states`] call for them. Refs
957    /// whose state this run already verified, and refs with no entry, need no
958    /// call.
959    fn new(
960        bin: &Path,
961        cache: &IssueCache,
962        refs: &[ItemRef],
963        listed: Option<ItemState>,
964        kind: impl Fn(&IssueDoc) -> bool,
965    ) -> Self {
966        if let Some(state) = listed {
967            return Self::Listed(state);
968        }
969        let cached: Vec<ItemRef> = cache
970            .unverified_refs(refs, kind)
971            .into_iter()
972            .cloned()
973            .collect();
974        let states = if cached.is_empty() {
975            Vec::new()
976        } else {
977            fetch_states(bin, &cached).unwrap_or_else(|e| {
978                warn!("Could not recheck cached GitHub state, refetching it: {e:#}");
979                vec![None; cached.len()]
980            })
981        };
982        cache.mark_state_verified(&cached);
983        Self::Rechecked(
984            cached
985                .iter()
986                .zip(states)
987                .map(|(item_ref, state)| {
988                    (
989                        (item_ref.project.to_ascii_lowercase(), item_ref.number),
990                        state,
991                    )
992                })
993                .collect(),
994        )
995    }
996
997    /// Whether `doc`'s cached `state` is the one GitHub reported. A doc that
998    /// was not rechecked is trusted only if this run verified or fetched it
999    /// itself.
1000    fn accepts(&self, cache: &IssueCache, doc: &IssueDoc) -> bool {
1001        match self {
1002            Self::Listed(state) => doc.state == *state,
1003            Self::Rechecked(current) => {
1004                match current.get(&(doc.project.to_ascii_lowercase(), doc.number)) {
1005                    Some(state) => *state == Some(doc.state),
1006                    None => cache.state_verified(&doc.project, doc.number),
1007                }
1008            }
1009        }
1010    }
1011}
1012
1013/// The lookup / fetch-the-misses / store / restore-caller-order sequence
1014/// shared by [`fetch_issues_cached`] and [`fetch_items_cached`], which differ
1015/// only in the slot type (`IssueDoc` or `Option<IssueDoc>`).
1016///
1017/// `lookup` serves one ref from the cache, `fetch` fetches exactly the refs
1018/// that missed (in order), and `store` caches each fetched slot.
1019fn fetch_through_cache<T>(
1020    refs: &[ItemRef],
1021    lookup: impl Fn(&ItemRef) -> Option<T>,
1022    fetch: impl FnOnce(&[ItemRef]) -> Result<Vec<T>>,
1023    store: impl Fn(&T),
1024) -> Result<Vec<T>> {
1025    let mut found: Vec<Option<T>> = refs.iter().map(lookup).collect();
1026    let misses = missing_refs(refs, &found);
1027    if !misses.is_empty() {
1028        let mut fetched = fetch(&misses)?.into_iter();
1029        for slot in found.iter_mut().filter(|slot| slot.is_none()) {
1030            let item = fetched
1031                .next()
1032                .context("the fetch returned fewer items than asked for (bug)")?;
1033            store(&item);
1034            *slot = Some(item);
1035        }
1036    }
1037    refs.iter()
1038        .zip(found)
1039        .map(|(item_ref, item)| {
1040            item.ok_or_else(|| anyhow!("item {item_ref} missing after the fetch (bug)"))
1041        })
1042        .collect()
1043}
1044
1045/// The `account_scope` digest of the `gh` login the fetches
1046/// will run as.
1047///
1048/// `None` if `gh` can't say (not installed, not logged in), in which case the
1049/// cache stays off: an entry could later be served to a different login.
1050/// **Blocking.**
1051#[must_use]
1052pub fn auth_scope(bin: &Path) -> Option<String> {
1053    match checked_auth_scope(bin) {
1054        Ok(scope) => Some(scope),
1055        Err(e) => {
1056            debug!("No usable gh login for the GitHub fetch cache: {e:#}");
1057            None
1058        }
1059    }
1060}
1061
1062/// [`auth_scope`] with the reason it failed. Asks `gh auth token`, which
1063/// honours `GH_HOST`, `GH_TOKEN` and the active `gh auth switch` account
1064/// exactly as `gh api` does, and reads local config only (no network). The
1065/// token is digested at once and never logged.
1066fn checked_auth_scope(bin: &Path) -> Result<String> {
1067    let output = crate::github_metrics::run_gh(bin, ["auth", "token"], "auth token", None)
1068        .with_context(|| format!("Failed to run {} auth token", bin.display()))?;
1069    if !output.status.success() {
1070        bail!("gh auth token failed (is `gh` logged in?)");
1071    }
1072    let token = String::from_utf8_lossy(&output.stdout);
1073    let token = token.trim();
1074    if token.is_empty() {
1075        bail!("gh auth token printed no token");
1076    }
1077    Ok(cache::account_scope(token))
1078}
1079
1080/// The cache `route` and `verify-decision` fetch through.
1081///
1082/// Configured from `env`, rooted under `base` (`dirs::cache_dir()`),
1083/// namespaced to the `gh` login (see [`auth_scope`]), and swept of expired
1084/// entries. **Blocking.**
1085#[must_use]
1086pub fn open_cache(
1087    env: &impl EnvSource,
1088    base: Option<std::path::PathBuf>,
1089    bin: &Path,
1090    refresh: bool,
1091) -> IssueCache {
1092    let cache = IssueCache::from_env_with(env, base, auth_scope(bin).as_deref(), refresh);
1093    cache.prune_expired();
1094    cache
1095}
1096
1097/// [`open_cache`] on a blocking thread, shared by the two commands that
1098/// fetch through the cache.
1099pub async fn open_cache_blocking<E>(
1100    env: E,
1101    base: Option<std::path::PathBuf>,
1102    bin: std::path::PathBuf,
1103    refresh: bool,
1104) -> Result<std::sync::Arc<IssueCache>>
1105where
1106    E: EnvSource + Send + 'static,
1107{
1108    tokio::task::spawn_blocking(move || std::sync::Arc::new(open_cache(&env, base, &bin, refresh)))
1109        .await
1110        .context("GitHub cache setup task panicked")
1111}
1112
1113/// The refs whose `found` slot is still empty, in order.
1114fn missing_refs<T>(refs: &[ItemRef], found: &[Option<T>]) -> Vec<ItemRef> {
1115    refs.iter()
1116        .zip(found)
1117        .filter(|(_, slot)| slot.is_none())
1118        .map(|(item_ref, _)| item_ref.clone())
1119        .collect()
1120}
1121
1122#[cfg(test)]
1123#[allow(clippy::unwrap_used, clippy::expect_used)]
1124mod tests {
1125    use super::*;
1126    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
1127    use std::path::PathBuf;
1128    use std::sync::MutexGuard;
1129
1130    // ── parse_issue_arg ─────────────────────────────────────────────
1131
1132    #[test]
1133    fn parses_bare_hash_number_with_default_project() {
1134        let item_ref = parse_issue_arg("#1779", Some("rust-works/omni-dev")).unwrap();
1135        assert_eq!(item_ref.project, "rust-works/omni-dev");
1136        assert_eq!(item_ref.number, 1779);
1137        assert_eq!(item_ref.kind, ItemKind::Issue);
1138    }
1139
1140    #[test]
1141    fn parses_bare_number_with_default_project() {
1142        let item_ref = parse_issue_arg("1779", Some("rust-works/omni-dev")).unwrap();
1143        assert_eq!(item_ref.number, 1779);
1144    }
1145
1146    #[test]
1147    fn bare_number_without_default_project_errors() {
1148        let err = parse_issue_arg("1779", None).unwrap_err();
1149        assert!(err.to_string().contains("has no repository"));
1150    }
1151
1152    #[test]
1153    fn parses_owner_repo_hash_number() {
1154        let item_ref = parse_issue_arg("rust-works/omni-dev#1779", None).unwrap();
1155        assert_eq!(item_ref.project, "rust-works/omni-dev");
1156        assert_eq!(item_ref.number, 1779);
1157    }
1158
1159    #[test]
1160    fn owner_repo_hash_number_ignores_default_project() {
1161        let item_ref = parse_issue_arg("other/repo#42", Some("rust-works/omni-dev")).unwrap();
1162        assert_eq!(item_ref.project, "other/repo");
1163        assert_eq!(item_ref.number, 42);
1164    }
1165
1166    #[test]
1167    fn parses_full_issue_url() {
1168        let item_ref =
1169            parse_issue_arg("https://github.com/rust-works/omni-dev/issues/1779", None).unwrap();
1170        assert_eq!(item_ref.project, "rust-works/omni-dev");
1171        assert_eq!(item_ref.number, 1779);
1172    }
1173
1174    #[test]
1175    fn full_issue_url_ignores_trailing_content() {
1176        let item_ref = parse_issue_arg(
1177            "https://github.com/rust-works/omni-dev/issues/1779?tab=comments",
1178            None,
1179        )
1180        .unwrap();
1181        assert_eq!(item_ref.number, 1779);
1182    }
1183
1184    #[test]
1185    fn rejects_a_pull_request_url() {
1186        let err =
1187            parse_issue_arg("https://github.com/rust-works/omni-dev/pull/1779", None).unwrap_err();
1188        assert!(err
1189            .to_string()
1190            .contains("not a recognised GitHub issue URL"));
1191    }
1192
1193    #[test]
1194    fn rejects_garbage_input() {
1195        let err = parse_issue_arg("not an issue", None).unwrap_err();
1196        assert!(err.to_string().contains("not a recognised issue reference"));
1197    }
1198
1199    #[test]
1200    fn rejects_a_nested_or_empty_project() {
1201        for raw in ["a/b/c#1", "/repo#1", "owner/#1", "own er/repo#1"] {
1202            let err = parse_issue_arg(raw, None).unwrap_err();
1203            assert!(err.to_string().contains("owner/repo"), "{raw}: {err}");
1204        }
1205    }
1206
1207    #[test]
1208    fn rejects_non_numeric_hash_number() {
1209        let err = parse_issue_arg("owner/repo#abc", None).unwrap_err();
1210        assert!(err.to_string().contains("valid issue number"));
1211    }
1212
1213    // ── build_issue_query ───────────────────────────────────────────
1214
1215    fn item_ref(project: &str, number: u64) -> ItemRef {
1216        ItemRef {
1217            provider: GitProvider::GitHub,
1218            project: project.to_string(),
1219            kind: ItemKind::Issue,
1220            number,
1221        }
1222    }
1223
1224    #[test]
1225    fn build_issue_query_is_none_for_no_refs() {
1226        assert!(build_issue_query(&[]).is_none());
1227    }
1228
1229    #[test]
1230    fn build_item_query_is_none_for_no_refs() {
1231        assert!(build_item_query(&[]).is_none());
1232    }
1233
1234    #[test]
1235    fn build_issue_query_groups_by_project() {
1236        let (query, index) = build_issue_query(&[
1237            item_ref("rust-works/omni-dev", 1),
1238            item_ref("rust-works/omni-dev", 2),
1239            item_ref("other/repo", 3),
1240        ])
1241        .unwrap();
1242        assert_eq!(query.matches("repository(owner:").count(), 2);
1243        assert_eq!(index.len(), 3);
1244    }
1245
1246    // ── fetch_issues (fake-gh shim) ──────────────────────────────────
1247
1248    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1249        let guard = shim_lock();
1250        let path = dir.join("fake-gh");
1251        write_exec_script(
1252            &path,
1253            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
1254        );
1255        (path, guard)
1256    }
1257
1258    #[test]
1259    fn fetch_issues_empty_refs_runs_nothing() {
1260        let docs = fetch_issues(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1261        assert!(docs.is_empty());
1262    }
1263
1264    #[test]
1265    fn fetch_issues_parses_a_single_issue() {
1266        let dir = tempfile::tempdir().unwrap();
1267        let (bin, _shim) = fake_gh(
1268            dir.path(),
1269            &serde_json::json!({
1270                "data": {
1271                    "r0": {
1272                        "i0": {
1273                            "title": "Route issues by stage",
1274                            "body": "Body text",
1275                            "state": "OPEN",
1276                            "url": "https://github.com/rust-works/omni-dev/issues/1779",
1277                            "comments": {"nodes": [
1278                                {"author": {"login": "newhoggy"}, "body": "A human comment"},
1279                                {"author": {"login": "github-actions[bot]"}, "body": "drift report"}
1280                            ]},
1281                            "closedByPullRequestsReferences": {"nodes": []}
1282                        }
1283                    }
1284                }
1285            })
1286            .to_string(),
1287            0,
1288        );
1289        let docs =
1290            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1779)]))
1291                .unwrap();
1292        assert_eq!(docs.len(), 1);
1293        let doc = &docs[0];
1294        assert_eq!(doc.title, "Route issues by stage");
1295        assert_eq!(doc.state, ItemState::Open);
1296        assert_eq!(doc.comments.len(), 1);
1297        assert_eq!(doc.comments[0].author, "newhoggy");
1298    }
1299
1300    #[test]
1301    fn fetch_issues_preserves_caller_order_across_projects() {
1302        let dir = tempfile::tempdir().unwrap();
1303        let issue = |title: &str| {
1304            serde_json::json!({
1305                "title": title, "body": "", "state": "OPEN",
1306                "url": "https://example.com", "comments": {"nodes": []},
1307                "closedByPullRequestsReferences": {"nodes": []}
1308            })
1309        };
1310        let (bin, _shim) = fake_gh(
1311            dir.path(),
1312            &serde_json::json!({
1313                "data": {
1314                    "r0": {"i0": issue("second")},
1315                    "r1": {"i0": issue("first")},
1316                }
1317            })
1318            .to_string(),
1319            0,
1320        );
1321        // Alphabetical project grouping puts "other/repo" (r0) before
1322        // "rust-works/omni-dev" (r1), opposite the caller's requested order.
1323        let refs = [
1324            item_ref("rust-works/omni-dev", 1),
1325            item_ref("other/repo", 2),
1326        ];
1327        let docs = retry_on_etxtbsy(|| fetch_issues(&bin, &refs)).unwrap();
1328        assert_eq!(docs[0].title, "first");
1329        assert_eq!(docs[1].title, "second");
1330    }
1331
1332    #[test]
1333    fn fetch_issues_errors_on_missing_issue() {
1334        let dir = tempfile::tempdir().unwrap();
1335        let (bin, _shim) = fake_gh(
1336            dir.path(),
1337            &serde_json::json!({"data": {"r0": {"i0": null}}}).to_string(),
1338            0,
1339        );
1340        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
1341            .unwrap_err();
1342        assert!(err.to_string().contains("was not found"));
1343    }
1344
1345    #[test]
1346    fn fetch_issues_errors_on_graphql_errors_array() {
1347        let dir = tempfile::tempdir().unwrap();
1348        let (bin, _shim) = fake_gh(
1349            dir.path(),
1350            &serde_json::json!({"errors": [{"message": "Something broke"}]}).to_string(),
1351            0,
1352        );
1353        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
1354            .unwrap_err();
1355        assert_eq!(err.to_string(), "gh api graphql failed: Something broke");
1356    }
1357
1358    /// The real API's shape for a missing issue: a `NOT_FOUND` error with a
1359    /// path, next to a `null` node.
1360    #[test]
1361    fn fetch_issues_names_a_missing_issue_from_a_not_found_error() {
1362        let dir = tempfile::tempdir().unwrap();
1363        let (bin, _shim) = fake_gh(
1364            dir.path(),
1365            &serde_json::json!({
1366                "data": {"r0": {"i0": null}},
1367                "errors": [{
1368                    "type": "NOT_FOUND", "path": ["r0", "i0"],
1369                    "message": "Could not resolve to an Issue with the number of 9999."
1370                }]
1371            })
1372            .to_string(),
1373            0,
1374        );
1375        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
1376            .unwrap_err();
1377        let msg = err.to_string();
1378        assert!(
1379            msg.contains("issue rust-works/omni-dev#9999 was not found"),
1380            "{msg}"
1381        );
1382        assert!(msg.contains("pull request"), "{msg}");
1383    }
1384
1385    #[test]
1386    fn fetch_issues_names_a_missing_repository() {
1387        let dir = tempfile::tempdir().unwrap();
1388        let (bin, _shim) = fake_gh(
1389            dir.path(),
1390            &serde_json::json!({
1391                "data": {"r0": null},
1392                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
1393            })
1394            .to_string(),
1395            0,
1396        );
1397        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("no/such", 1)])).unwrap_err();
1398        assert!(
1399            err.to_string().contains("repository no/such was not found"),
1400            "{err}"
1401        );
1402    }
1403
1404    #[test]
1405    fn fetch_issues_filters_bot_comments() {
1406        let dir = tempfile::tempdir().unwrap();
1407        let (bin, _shim) = fake_gh(
1408            dir.path(),
1409            &serde_json::json!({
1410                "data": {"r0": {"i0": {
1411                    "title": "t", "body": "b", "state": "CLOSED",
1412                    "url": "https://example.com",
1413                    "comments": {"nodes": [
1414                        {"author": {"login": "dependabot[bot]"}, "body": "bump"},
1415                        {"author": {"login": "human"}, "body": "hi"}
1416                    ]},
1417                    "closedByPullRequestsReferences": {"nodes": [{"number": 42}]}
1418                }}}
1419            })
1420            .to_string(),
1421            0,
1422        );
1423        let docs =
1424            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1)])).unwrap();
1425        assert_eq!(docs[0].comments.len(), 1);
1426        assert_eq!(docs[0].comments[0].author, "human");
1427        assert_eq!(docs[0].state, ItemState::Closed);
1428        assert_eq!(docs[0].closed_by.len(), 1);
1429        assert_eq!(docs[0].closed_by[0].number, 42);
1430        assert_eq!(docs[0].closed_by[0].kind, ItemKind::ChangeRequest);
1431    }
1432
1433    #[test]
1434    fn fetch_issues_filters_bots_by_typename_and_keeps_deleted_authors() {
1435        let dir = tempfile::tempdir().unwrap();
1436        let (bin, _shim) = fake_gh(
1437            dir.path(),
1438            &serde_json::json!({
1439                "data": {"r0": {"i0": {
1440                    "title": "t", "body": "b", "state": "OPEN",
1441                    "url": "https://example.com",
1442                    "comments": {"totalCount": 3, "nodes": [
1443                        {"author": {"__typename": "Bot", "login": "github-actions"}, "body": "drift"},
1444                        {"author": null, "body": "from a deleted account"},
1445                        {"author": {"__typename": "User", "login": "human"}, "body": "hi"}
1446                    ]},
1447                    "closedByPullRequestsReferences": {"nodes": []}
1448                }}}
1449            })
1450            .to_string(),
1451            0,
1452        );
1453        let docs =
1454            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1)])).unwrap();
1455        let authors: Vec<&str> = docs[0].comments.iter().map(|c| c.author.as_str()).collect();
1456        assert_eq!(authors, ["ghost", "human"]);
1457    }
1458
1459    #[test]
1460    fn fetch_issues_splits_large_batches_across_queries() {
1461        let dir = tempfile::tempdir().unwrap();
1462        let issue = serde_json::json!({
1463            "title": "t", "body": "", "state": "OPEN", "url": "https://example.com",
1464            "comments": {"totalCount": 0, "nodes": []},
1465            "closedByPullRequestsReferences": {"nodes": []}
1466        });
1467        // One canned reply answers every chunk: each chunk is a single
1468        // project, so its aliases are r0/i0..i{n}.
1469        let repo: serde_json::Map<String, Value> = (0..MAX_ISSUES_PER_QUERY)
1470            .map(|i| (format!("i{i}"), issue.clone()))
1471            .collect();
1472        let (bin, _shim) = fake_gh(
1473            dir.path(),
1474            &serde_json::json!({"data": {"r0": repo}}).to_string(),
1475            0,
1476        );
1477        let refs: Vec<ItemRef> = (1..=MAX_ISSUES_PER_QUERY as u64 + 1)
1478            .map(|n| item_ref("rust-works/omni-dev", n))
1479            .collect();
1480        let docs = retry_on_etxtbsy(|| fetch_issues(&bin, &refs)).unwrap();
1481        assert_eq!(docs.len(), refs.len());
1482        assert_eq!(docs.last().unwrap().number, MAX_ISSUES_PER_QUERY as u64 + 1);
1483    }
1484
1485    #[test]
1486    fn issue_fragment_fetches_latest_comments_with_author_type() {
1487        let fragment = issue_fragment("i0", 7);
1488        assert!(fragment.contains(&format!("comments(last:{MAX_COMMENTS})")));
1489        assert!(fragment.contains("totalCount"));
1490        assert!(fragment.contains("__typename"));
1491    }
1492
1493    // ── needs_default_project ────────────────────────────────────────
1494
1495    #[test]
1496    fn needs_default_project_only_for_bare_numbers() {
1497        assert!(needs_default_project("#12"));
1498        assert!(needs_default_project(" 12 "));
1499        assert!(!needs_default_project("owner/repo#12"));
1500        assert!(!needs_default_project(
1501            "https://github.com/rust-works/omni-dev/issues/12"
1502        ));
1503        assert!(!needs_default_project("#"));
1504        assert!(!needs_default_project("abc"));
1505    }
1506
1507    // ── resolve_current_project / list_open_issue_numbers ────────────
1508
1509    #[test]
1510    fn resolve_current_project_errors_when_gh_is_missing() {
1511        let err =
1512            resolve_current_project(Path::new("/no/such/gh/xyzzy"), Path::new(".")).unwrap_err();
1513        assert!(err.to_string().contains("Failed to run"));
1514    }
1515
1516    #[test]
1517    fn resolve_current_project_parses_stdout() {
1518        let dir = tempfile::tempdir().unwrap();
1519        let (bin, _shim) = fake_gh(dir.path(), "rust-works/omni-dev", 0);
1520        let project = retry_on_etxtbsy(|| resolve_current_project(&bin, dir.path())).unwrap();
1521        assert_eq!(project, "rust-works/omni-dev");
1522    }
1523
1524    #[test]
1525    fn resolve_current_project_errors_on_nonzero_exit() {
1526        let dir = tempfile::tempdir().unwrap();
1527        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1528        let err = retry_on_etxtbsy(|| resolve_current_project(&bin, dir.path())).unwrap_err();
1529        assert!(err.to_string().contains("gh repo view failed"));
1530    }
1531
1532    #[test]
1533    fn list_open_issue_numbers_parses_numbers() {
1534        let dir = tempfile::tempdir().unwrap();
1535        let (bin, _shim) = fake_gh(
1536            dir.path(),
1537            &serde_json::json!([{"number": 1}, {"number": 2}]).to_string(),
1538            0,
1539        );
1540        let numbers =
1541            retry_on_etxtbsy(|| list_open_issue_numbers(&bin, "rust-works/omni-dev")).unwrap();
1542        assert_eq!(numbers, vec![1, 2]);
1543    }
1544
1545    #[test]
1546    fn list_open_issue_numbers_errors_when_gh_is_missing() {
1547        let err = list_open_issue_numbers(Path::new("/no/such/gh/xyzzy"), "rust-works/omni-dev")
1548            .unwrap_err();
1549        assert!(err.to_string().contains("Failed to run"));
1550    }
1551
1552    #[test]
1553    fn list_open_issue_numbers_errors_on_nonzero_exit() {
1554        let dir = tempfile::tempdir().unwrap();
1555        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1556        let err =
1557            retry_on_etxtbsy(|| list_open_issue_numbers(&bin, "rust-works/omni-dev")).unwrap_err();
1558        assert!(err.to_string().contains("gh issue list failed"));
1559    }
1560
1561    // ── fetch_items (fake-gh shim) ────────────────────────────────────
1562
1563    fn pr_ref(project: &str, number: u64) -> ItemRef {
1564        ItemRef {
1565            provider: GitProvider::GitHub,
1566            project: project.to_string(),
1567            kind: ItemKind::ChangeRequest,
1568            number,
1569        }
1570    }
1571
1572    #[test]
1573    fn fetch_items_resolves_an_issue_by_typename() {
1574        let dir = tempfile::tempdir().unwrap();
1575        let (bin, _shim) = fake_gh(
1576            dir.path(),
1577            &serde_json::json!({
1578                "data": {"r0": {"i0": {
1579                    "__typename": "Issue",
1580                    "title": "t", "body": "b", "state": "OPEN", "url": "u",
1581                    "comments": {"totalCount": 1, "nodes": [
1582                        {"databaseId": 42, "author": {"login": "human"}, "body": "hi"}
1583                    ]},
1584                    "closedByPullRequestsReferences": {"nodes": [{"number": 7}]}
1585                }}}
1586            })
1587            .to_string(),
1588            0,
1589        );
1590        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1614)]))
1591            .unwrap();
1592        let doc = docs[0].as_ref().unwrap();
1593        assert_eq!(doc.kind, ItemKind::Issue);
1594        assert_eq!(doc.comments[0].id, Some(42));
1595        assert_eq!(doc.closed_by[0].number, 7);
1596        assert_eq!(doc.closed_by[0].kind, ItemKind::ChangeRequest);
1597    }
1598
1599    #[test]
1600    fn fetch_items_resolves_a_pull_request_by_typename_even_when_cited_as_an_issue() {
1601        let dir = tempfile::tempdir().unwrap();
1602        let (bin, _shim) = fake_gh(
1603            dir.path(),
1604            &serde_json::json!({
1605                "data": {"r0": {"i0": {
1606                    "__typename": "PullRequest",
1607                    "title": "t", "body": "b", "state": "MERGED", "url": "u"
1608                }}}
1609            })
1610            .to_string(),
1611            0,
1612        );
1613        // Cited with an Issue kind hint (e.g. "#1629"); __typename overrides it.
1614        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1629)]))
1615            .unwrap();
1616        let doc = docs[0].as_ref().unwrap();
1617        assert_eq!(doc.kind, ItemKind::ChangeRequest);
1618        assert_eq!(doc.state, ItemState::Closed);
1619        assert!(doc.comments.is_empty());
1620    }
1621
1622    #[test]
1623    fn fetch_items_tolerates_a_not_found_reference() {
1624        let dir = tempfile::tempdir().unwrap();
1625        let (bin, _shim) = fake_gh(
1626            dir.path(),
1627            &serde_json::json!({
1628                "data": {"r0": {"i0": null}},
1629                "errors": [{
1630                    "type": "NOT_FOUND", "path": ["r0", "i0"],
1631                    "message": "Could not resolve to an issue or pull request."
1632                }]
1633            })
1634            .to_string(),
1635            0,
1636        );
1637        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[pr_ref("rust-works/omni-dev", 99999)]))
1638            .unwrap();
1639        assert!(docs[0].is_none());
1640    }
1641
1642    #[test]
1643    fn fetch_items_keeps_valid_items_when_gh_exits_nonzero_for_one_missing_item() {
1644        let dir = tempfile::tempdir().unwrap();
1645        let (bin, _shim) = fake_gh(
1646            dir.path(),
1647            &serde_json::json!({
1648                "data": {"r0": {
1649                    "i0": null,
1650                    "i1": {
1651                        "__typename": "Issue",
1652                        "title": "Valid", "body": "b",
1653                        "state": "OPEN", "url": "u"
1654                    }
1655                }},
1656                "errors": [{
1657                    "type": "NOT_FOUND", "path": ["r0", "i0"],
1658                    "message": "Could not resolve to an issue or pull request."
1659                }]
1660            })
1661            .to_string(),
1662            1,
1663        );
1664        let refs = [
1665            item_ref("rust-works/omni-dev", 2063),
1666            item_ref("rust-works/omni-dev", 1871),
1667        ];
1668        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1669        assert!(docs[0].is_none());
1670        assert_eq!(docs[1].as_ref().unwrap().title, "Valid");
1671    }
1672
1673    /// #2001: a repository-level `NOT_FOUND` (a quoted `owner/repo#123`)
1674    /// resolves every item cited in that repository to `None`.
1675    #[test]
1676    fn fetch_items_tolerates_a_missing_repository() {
1677        let dir = tempfile::tempdir().unwrap();
1678        let (bin, _shim) = fake_gh(
1679            dir.path(),
1680            &serde_json::json!({
1681                "data": {"r0": null},
1682                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
1683            })
1684            .to_string(),
1685            1,
1686        );
1687        let refs = [pr_ref("no/such", 1), item_ref("no/such", 2)];
1688        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1689        assert!(docs.iter().all(Option::is_none), "{docs:?}");
1690    }
1691
1692    /// A missing repository doesn't take down items in a repository that
1693    /// exists: the partial-data reply's valid node still resolves.
1694    #[test]
1695    fn fetch_items_keeps_items_in_other_repositories_when_one_is_missing() {
1696        let dir = tempfile::tempdir().unwrap();
1697        // `build_item_query` aliases projects in sorted order, so `no/such`
1698        // is `r0` and `rust-works/omni-dev` is `r1`.
1699        let (bin, _shim) = fake_gh(
1700            dir.path(),
1701            &serde_json::json!({
1702                "data": {
1703                    "r0": null,
1704                    "r1": {"i0": {
1705                        "__typename": "Issue",
1706                        "title": "Valid", "body": "b",
1707                        "state": "OPEN", "url": "u"
1708                    }}
1709                },
1710                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
1711            })
1712            .to_string(),
1713            1,
1714        );
1715        let refs = [
1716            item_ref("rust-works/omni-dev", 1871),
1717            item_ref("no/such", 123),
1718        ];
1719        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1720        assert_eq!(docs[0].as_ref().unwrap().title, "Valid");
1721        assert!(docs[1].is_none());
1722    }
1723
1724    /// A repository-level `NOT_FOUND` on an alias this query never issued
1725    /// isn't a citation miss we can attribute, so it still fails the batch.
1726    #[test]
1727    fn fetch_items_bails_on_a_not_found_for_an_unknown_repository_alias() {
1728        let dir = tempfile::tempdir().unwrap();
1729        let (bin, _shim) = fake_gh(
1730            dir.path(),
1731            &serde_json::json!({
1732                "data": {"r0": {"i0": null}},
1733                "errors": [{"type": "NOT_FOUND", "path": ["r7"], "message": "Could not resolve"}]
1734            })
1735            .to_string(),
1736            1,
1737        );
1738        let err = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1)]))
1739            .unwrap_err();
1740        assert!(err.to_string().contains("was not found"), "{err}");
1741    }
1742
1743    #[test]
1744    fn item_fragment_uses_the_polymorphic_field() {
1745        let fragment = item_fragment("i0", 42);
1746        assert!(fragment.contains("issueOrPullRequest(number:42)"));
1747        assert!(fragment.contains("__typename"));
1748        assert!(fragment.contains("... on Issue"));
1749        assert!(fragment.contains("... on PullRequest"));
1750    }
1751
1752    // ── build_item_doc ─────────────────────────────────────────────────
1753
1754    #[test]
1755    fn build_item_doc_rejects_an_unrecognised_typename() {
1756        let node = serde_json::json!({
1757            "__typename": "Gist", "title": "t", "body": "b", "state": "OPEN", "url": "u"
1758        });
1759        let err = build_item_doc(&item_ref("rust-works/omni-dev", 1), &node).unwrap_err();
1760        assert!(err.to_string().contains("unrecognised __typename"), "{err}");
1761    }
1762
1763    #[test]
1764    fn build_item_doc_rejects_an_unrecognised_state() {
1765        let node = serde_json::json!({
1766            "__typename": "Issue", "title": "t", "body": "b", "state": "DRAFT", "url": "u"
1767        });
1768        let err = build_item_doc(&item_ref("rust-works/omni-dev", 1), &node).unwrap_err();
1769        assert!(err.to_string().contains("unrecognised state"), "{err}");
1770    }
1771
1772    /// A node missing from the reply with no accompanying GraphQL error
1773    /// (rather than an explicit `NOT_FOUND`) is still treated as not found.
1774    #[test]
1775    fn fetch_items_treats_a_silently_missing_node_as_not_found() {
1776        let dir = tempfile::tempdir().unwrap();
1777        let (bin, _shim) = fake_gh(
1778            dir.path(),
1779            &serde_json::json!({"data": {"r0": {"i0": null}}}).to_string(),
1780            0,
1781        );
1782        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1614)]))
1783            .unwrap();
1784        assert!(docs[0].is_none());
1785    }
1786
1787    // ── fetch_*_cached (fake-gh shim + on-disk cache) ─────────────────
1788
1789    /// A fake `gh` that appends a line to `<dir>/calls` per invocation, so a
1790    /// test can assert how many times the network was reached.
1791    fn counting_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1792        let guard = shim_lock();
1793        let path = dir.join("fake-gh");
1794        let calls = dir.join("calls");
1795        write_exec_script(
1796            &path,
1797            &format!(
1798                "#!/bin/sh\necho call >> '{}'\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n",
1799                calls.display()
1800            ),
1801        );
1802        (path, guard)
1803    }
1804
1805    fn gh_calls(dir: &Path) -> usize {
1806        std::fs::read_to_string(dir.join("calls")).map_or(0, |s| s.lines().count())
1807    }
1808
1809    fn one_issue_reply(title: &str) -> String {
1810        serde_json::json!({"data": {"r0": {"i0": {
1811            "__typename": "Issue",
1812            "title": title, "body": "b", "state": "OPEN", "url": "u",
1813            "comments": {"totalCount": 0, "nodes": []},
1814            "closedByPullRequestsReferences": {"nodes": []}
1815        }}}})
1816        .to_string()
1817    }
1818
1819    #[test]
1820    fn fetch_issues_cached_reuses_a_fresh_fetch_without_calling_gh() {
1821        let dir = tempfile::tempdir().unwrap();
1822        let cache_dir = tempfile::tempdir().unwrap();
1823        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1824        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("first"), 0);
1825        let refs = [item_ref("rust-works/omni-dev", 1)];
1826
1827        let first = retry_on_etxtbsy(|| fetch_issues_cached(&bin, &cache, &refs)).unwrap();
1828        assert_eq!(gh_calls(dir.path()), 1);
1829        assert!(cache.reuse_note().is_none());
1830
1831        // A later run: a new cache instance over the same directory.
1832        let later = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1833        let second = retry_on_etxtbsy(|| fetch_issues_cached(&bin, &later, &refs)).unwrap();
1834        assert_eq!(
1835            gh_calls(dir.path()),
1836            1,
1837            "the second fetch must be served from disk"
1838        );
1839        assert_eq!(first, second);
1840        assert!(later.reuse_note().is_some());
1841    }
1842
1843    #[test]
1844    fn fetch_issues_cached_fetches_only_misses_and_keeps_caller_order() {
1845        let dir = tempfile::tempdir().unwrap();
1846        let cache_dir = tempfile::tempdir().unwrap();
1847        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1848        {
1849            let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("cached"), 0);
1850            retry_on_etxtbsy(|| {
1851                fetch_issues_cached(&bin, &cache, &[item_ref("rust-works/omni-dev", 1)])
1852            })
1853            .unwrap();
1854        }
1855        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("fresh"), 0);
1856        let refs = [
1857            item_ref("rust-works/omni-dev", 2),
1858            item_ref("rust-works/omni-dev", 1),
1859        ];
1860        let docs = retry_on_etxtbsy(|| fetch_issues_cached(&bin, &cache, &refs)).unwrap();
1861        assert_eq!(gh_calls(dir.path()), 2);
1862        assert_eq!((docs[0].number, docs[0].title.as_str()), (2, "fresh"));
1863        assert_eq!((docs[1].number, docs[1].title.as_str()), (1, "cached"));
1864    }
1865
1866    #[test]
1867    fn refresh_bypasses_the_cache_for_fetch_issues_cached() {
1868        let dir = tempfile::tempdir().unwrap();
1869        let cache_dir = tempfile::tempdir().unwrap();
1870        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("t"), 0);
1871        let refs = [item_ref("rust-works/omni-dev", 1)];
1872        let normal = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1873        retry_on_etxtbsy(|| fetch_issues_cached(&bin, &normal, &refs)).unwrap();
1874        let refresh = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, true);
1875        retry_on_etxtbsy(|| fetch_issues_cached(&bin, &refresh, &refs)).unwrap();
1876        assert_eq!(gh_calls(dir.path()), 2);
1877    }
1878
1879    #[test]
1880    fn fetch_items_cached_shares_entries_with_fetch_issues_cached() {
1881        let dir = tempfile::tempdir().unwrap();
1882        let cache_dir = tempfile::tempdir().unwrap();
1883        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1884        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("t"), 0);
1885        let refs = [item_ref("rust-works/omni-dev", 1)];
1886        let item = retry_on_etxtbsy(|| fetch_items_cached(&bin, &cache, &refs)).unwrap();
1887        let issue = retry_on_etxtbsy(|| fetch_issues_cached(&bin, &cache, &refs)).unwrap();
1888        assert_eq!(gh_calls(dir.path()), 1);
1889        assert_eq!(item[0].as_ref(), Some(&issue[0]));
1890    }
1891
1892    #[test]
1893    fn a_cached_pull_request_is_not_served_as_an_issue() {
1894        let dir = tempfile::tempdir().unwrap();
1895        let cache_dir = tempfile::tempdir().unwrap();
1896        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1897        let reply = serde_json::json!({"data": {"r0": {"i0": {
1898            "__typename": "PullRequest",
1899            "title": "a PR", "body": "b", "state": "MERGED", "url": "u"
1900        }}}})
1901        .to_string();
1902        let (bin, _shim) = counting_gh(dir.path(), &reply, 0);
1903        let refs = [item_ref("rust-works/omni-dev", 5)];
1904        retry_on_etxtbsy(|| fetch_items_cached(&bin, &cache, &refs)).unwrap();
1905        // Uncached, the issue query fails on a pull request (here, on its
1906        // `MERGED` state); it must still reach `gh` and fail the same way.
1907        assert!(retry_on_etxtbsy(|| fetch_issues_cached(&bin, &cache, &refs)).is_err());
1908        assert_eq!(gh_calls(dir.path()), 2);
1909    }
1910
1911    #[test]
1912    fn fetch_items_cached_does_not_cache_a_not_found_item() {
1913        let dir = tempfile::tempdir().unwrap();
1914        let cache_dir = tempfile::tempdir().unwrap();
1915        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1916        let reply = serde_json::json!({
1917            "data": {"r0": {"i0": null}},
1918            "errors": [{"type": "NOT_FOUND", "path": ["r0", "i0"], "message": "nope"}]
1919        })
1920        .to_string();
1921        let (bin, _shim) = counting_gh(dir.path(), &reply, 1);
1922        let refs = [item_ref("rust-works/omni-dev", 9)];
1923        for _ in 0..2 {
1924            let docs = retry_on_etxtbsy(|| fetch_items_cached(&bin, &cache, &refs)).unwrap();
1925            assert!(docs[0].is_none());
1926        }
1927        assert_eq!(gh_calls(dir.path()), 2);
1928    }
1929
1930    #[test]
1931    fn fetch_issues_refreshed_always_calls_gh_and_writes_through() {
1932        let dir = tempfile::tempdir().unwrap();
1933        let cache_dir = tempfile::tempdir().unwrap();
1934        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1935        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("t"), 0);
1936        let refs = [item_ref("rust-works/omni-dev", 1)];
1937        retry_on_etxtbsy(|| fetch_issues_refreshed(&bin, &cache, &refs)).unwrap();
1938        retry_on_etxtbsy(|| fetch_issues_refreshed(&bin, &cache, &refs)).unwrap();
1939        assert_eq!(gh_calls(dir.path()), 2);
1940        let later = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
1941        retry_on_etxtbsy(|| fetch_issues_cached(&bin, &later, &refs)).unwrap();
1942        assert_eq!(
1943            gh_calls(dir.path()),
1944            2,
1945            "the refreshed copy is reused later"
1946        );
1947    }
1948
1949    fn cached_doc(project: &str, number: u64) -> IssueDoc {
1950        IssueDoc {
1951            provider: GitProvider::GitHub,
1952            project: project.to_string(),
1953            number,
1954            kind: ItemKind::Issue,
1955            title: "t".to_string(),
1956            state: ItemState::Open,
1957            body: "b".to_string(),
1958            comments: Vec::new(),
1959            closed_by: Vec::new(),
1960            url: "u".to_string(),
1961        }
1962    }
1963
1964    // ── updatedAt recheck (#1815) ───────────────────────────────────
1965
1966    #[test]
1967    fn updated_at_query_groups_repositories_and_selects_only_timestamps() {
1968        assert!(build_updated_at_query(&[]).is_none());
1969        let (query, index) =
1970            build_updated_at_query(&[item_ref("a/b", 1), item_ref("c/d", 2), item_ref("a/b", 3)])
1971                .unwrap();
1972        assert_eq!(index.len(), 3);
1973        let expected = r#"query{
1974r0: repository(owner:"a", name:"b"){
1975i0: issueOrPullRequest(number:1){
1976      ... on Issue { updatedAt }
1977      ... on PullRequest { updatedAt }
1978    }
1979i1: issueOrPullRequest(number:3){
1980      ... on Issue { updatedAt }
1981      ... on PullRequest { updatedAt }
1982    }
1983}
1984r1: repository(owner:"c", name:"d"){
1985i0: issueOrPullRequest(number:2){
1986      ... on Issue { updatedAt }
1987      ... on PullRequest { updatedAt }
1988    }
1989}
1990}"#;
1991        assert_eq!(query, expected);
1992        assert_eq!(index[&(0, 1)].number, 3);
1993        assert_eq!(index[&(1, 0)].project, "c/d");
1994    }
1995
1996    #[test]
1997    fn fetch_updated_at_preserves_order_duplicates_and_normalizes_offsets() {
1998        assert!(fetch_updated_at(Path::new("/no/such/gh"), &[])
1999            .unwrap()
2000            .is_empty());
2001        let dir = tempfile::tempdir().unwrap();
2002        let reply = serde_json::json!({"data": {
2003            "r0": {"i0": {"updatedAt": "2026-10-02T10:00:00+10:00"}},
2004            "r1": {"i0": {"updatedAt": "2026-10-01T00:00:00Z"},
2005                   "i1": {"updatedAt": "2026-10-01T00:00:00Z"}}
2006        }})
2007        .to_string();
2008        let (bin, _shim) = fake_gh(dir.path(), &reply, 0);
2009        let mut pr = item_ref("a/b", 2);
2010        pr.kind = ItemKind::ChangeRequest;
2011        let refs = [item_ref("c/d", 1), pr, item_ref("c/d", 1)];
2012        let stamps = retry_on_etxtbsy(|| fetch_updated_at(&bin, &refs)).unwrap();
2013        let utc = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
2014        assert_eq!(
2015            stamps,
2016            [
2017                utc("2026-10-01T00:00:00Z"),
2018                utc("2026-10-02T00:00:00Z"),
2019                utc("2026-10-01T00:00:00Z")
2020            ]
2021        );
2022    }
2023
2024    #[test]
2025    fn updated_at_parser_rejects_invalid_timestamps_with_item_context() {
2026        let (_, index) = build_updated_at_query(&[item_ref("a/b", 42)]).unwrap();
2027        for node in [
2028            serde_json::json!({}),
2029            serde_json::json!({"updatedAt": null}),
2030            serde_json::json!({"updatedAt": 123}),
2031            serde_json::json!({"updatedAt": "yesterday"}),
2032        ] {
2033            let err = parse_updated_at_response(
2034                &serde_json::json!({"data": {"r0": {"i0": node}}}),
2035                &index,
2036            )
2037            .unwrap_err();
2038            assert!(err.to_string().contains("a/b#42"), "{err}");
2039            assert!(err.to_string().contains("updatedAt"), "{err}");
2040        }
2041    }
2042
2043    #[test]
2044    fn updated_at_parser_handles_missing_items_repositories_and_errors() {
2045        let (_, index) = build_updated_at_query(&[item_ref("a/b", 1)]).unwrap();
2046        for path in [serde_json::json!(["r0", "i0"]), serde_json::json!(["r0"])] {
2047            let body = serde_json::json!({"data": {"r0": null}, "errors": [{"type": "NOT_FOUND", "path": path}]});
2048            assert_eq!(
2049                parse_updated_at_response(&body, &index).unwrap()[&("a/b".to_string(), 1)],
2050                None
2051            );
2052        }
2053        let null = serde_json::json!({"data": {"r0": {"i0": null}}});
2054        assert_eq!(
2055            parse_updated_at_response(&null, &index).unwrap()[&("a/b".to_string(), 1)],
2056            None
2057        );
2058        for error in [
2059            serde_json::json!({"type": "RATE_LIMITED", "message": "slow down"}),
2060            serde_json::json!({"type": "NOT_FOUND", "path": ["r9"]}),
2061        ] {
2062            assert!(parse_updated_at_response(
2063                &serde_json::json!({"data": {}, "errors": [error]}),
2064                &index
2065            )
2066            .is_err());
2067        }
2068        assert!(parse_updated_at_response(&serde_json::json!({}), &index).is_err());
2069    }
2070
2071    #[test]
2072    fn fetch_updated_at_chunks_requests_and_propagates_subprocess_errors() {
2073        let dir = tempfile::tempdir().unwrap();
2074        let nodes: serde_json::Map<String, Value> = (0..MAX_ISSUES_PER_QUERY)
2075            .map(|i| {
2076                (
2077                    format!("i{i}"),
2078                    serde_json::json!({"updatedAt": "2026-10-01T00:00:00Z"}),
2079                )
2080            })
2081            .collect();
2082        let reply = serde_json::json!({"data": {"r0": nodes}}).to_string();
2083        let refs: Vec<_> = (1..=26).map(|n| item_ref("a/b", n)).collect();
2084        {
2085            let (bin, _shim) = counting_gh(dir.path(), &reply, 0);
2086            let stamps = retry_on_etxtbsy(|| fetch_updated_at(&bin, &refs)).unwrap();
2087            assert_eq!(stamps.len(), refs.len());
2088            assert!(stamps.iter().all(Option::is_some));
2089            assert_eq!(gh_calls(dir.path()), 2);
2090        }
2091        let (bin, _shim) = fake_gh(dir.path(), "unavailable", 1);
2092        assert!(retry_on_etxtbsy(|| fetch_updated_at(&bin, &refs)).is_err());
2093    }
2094
2095    // ── state recheck (#2041) ────────────────────────────────────────
2096
2097    /// A fake `gh` answering a `state`-only query with `state_reply` and any
2098    /// other query with `full_reply`, logging `state` or `full` per call.
2099    fn state_gh(
2100        dir: &Path,
2101        state_reply: &str,
2102        full_reply: &str,
2103    ) -> (PathBuf, MutexGuard<'static, ()>) {
2104        let guard = shim_lock();
2105        let path = dir.join("fake-gh");
2106        let calls = dir.join("kinds");
2107        write_exec_script(
2108            &path,
2109            &format!(
2110                "#!/bin/sh\ncase \"$4\" in\n\
2111                 *'on Issue {{ state }}'*) echo state >> '{calls}'; cat <<'JSON'\n{state_reply}\nJSON\n;;\n\
2112                 *) echo full >> '{calls}'; cat <<'JSON'\n{full_reply}\nJSON\n;;\n\
2113                 esac\n",
2114                calls = calls.display()
2115            ),
2116        );
2117        // Production code under test degrades a failed state recheck to a
2118        // refetch (#2041), which swallows an `ETXTBSY` from the first exec of
2119        // a freshly written shim and drops the expected `state` call. Exec it
2120        // once here, where `retry_on_etxtbsy` can see the error, then reset
2121        // the log.
2122        retry_on_etxtbsy(|| {
2123            std::process::Command::new(&path)
2124                .output()
2125                .map(drop)
2126                .map_err(Into::into)
2127        })
2128        .unwrap();
2129        std::fs::remove_file(&calls).unwrap();
2130        (path, guard)
2131    }
2132
2133    fn kinds(dir: &Path) -> Vec<String> {
2134        std::fs::read_to_string(dir.join("kinds"))
2135            .unwrap_or_default()
2136            .lines()
2137            .map(str::to_string)
2138            .collect()
2139    }
2140
2141    fn state_reply(state: &str) -> String {
2142        serde_json::json!({"data": {"r0": {"i0": {"__typename": "Issue", "state": state}}}})
2143            .to_string()
2144    }
2145
2146    fn issue_reply(state: &str) -> String {
2147        serde_json::json!({"data": {"r0": {"i0": {
2148            "__typename": "Issue",
2149            "title": "fresh", "body": "b", "state": state, "url": "u",
2150            "comments": {"totalCount": 0, "nodes": []},
2151            "closedByPullRequestsReferences": {"nodes": []}
2152        }}}})
2153        .to_string()
2154    }
2155
2156    fn cache_holding(cache_dir: &Path, state: ItemState) -> IssueCache {
2157        let cache = IssueCache::new(cache_dir.to_path_buf(), DEFAULT_CACHE_TTL, false);
2158        let mut doc = cached_doc("rust-works/omni-dev", 1);
2159        doc.title = "cached".to_string();
2160        doc.state = state;
2161        cache.store(&doc);
2162        // A later run: the entry is on disk, but this instance did not store it.
2163        IssueCache::new(cache_dir.to_path_buf(), DEFAULT_CACHE_TTL, false)
2164    }
2165
2166    #[test]
2167    fn build_state_query_is_none_for_no_refs() {
2168        assert!(build_state_query(&[]).is_none());
2169    }
2170
2171    #[test]
2172    fn build_state_query_asks_only_for_state_and_groups_by_project() {
2173        let (query, index) = build_state_query(&[
2174            item_ref("rust-works/omni-dev", 1),
2175            item_ref("rust-works/omni-dev", 2),
2176            item_ref("other/repo", 3),
2177        ])
2178        .unwrap();
2179        assert_eq!(query.matches("repository(owner:").count(), 2);
2180        assert_eq!(index.len(), 3);
2181        assert!(query.contains("... on Issue { state }"), "{query}");
2182        assert!(query.contains("... on PullRequest { state }"), "{query}");
2183        for text in ["title", "body", "comments"] {
2184            assert!(!query.contains(text), "{text} in {query}");
2185        }
2186    }
2187
2188    #[test]
2189    fn fetch_states_empty_refs_runs_nothing() {
2190        let states = fetch_states(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
2191        assert!(states.is_empty());
2192    }
2193
2194    #[test]
2195    fn fetch_states_maps_open_closed_merged_and_not_found() {
2196        let dir = tempfile::tempdir().unwrap();
2197        let reply = serde_json::json!({
2198            "data": {"r0": {
2199                "i0": {"__typename": "Issue", "state": "OPEN"},
2200                "i1": {"__typename": "Issue", "state": "CLOSED"},
2201                "i2": {"__typename": "PullRequest", "state": "MERGED"},
2202                "i3": null
2203            }},
2204            "errors": [{"type": "NOT_FOUND", "path": ["r0", "i3"], "message": "nope"}]
2205        })
2206        .to_string();
2207        let (bin, _shim) = fake_gh(dir.path(), &reply, 1);
2208        let refs: Vec<ItemRef> = (1..=4)
2209            .map(|n| item_ref("rust-works/omni-dev", n))
2210            .collect();
2211        let states = retry_on_etxtbsy(|| fetch_states(&bin, &refs)).unwrap();
2212        assert_eq!(
2213            states,
2214            [
2215                Some(ItemState::Open),
2216                Some(ItemState::Closed),
2217                Some(ItemState::Closed),
2218                None
2219            ]
2220        );
2221    }
2222
2223    #[test]
2224    fn fetch_states_reports_a_missing_repository_as_not_found() {
2225        let dir = tempfile::tempdir().unwrap();
2226        let reply = serde_json::json!({
2227            "data": {"r0": null},
2228            "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "nope"}]
2229        })
2230        .to_string();
2231        let (bin, _shim) = fake_gh(dir.path(), &reply, 1);
2232        let states = retry_on_etxtbsy(|| fetch_states(&bin, &[item_ref("gone/repo", 1)])).unwrap();
2233        assert_eq!(states, [None]);
2234    }
2235
2236    #[test]
2237    fn fetch_states_fails_on_any_other_graphql_error() {
2238        let dir = tempfile::tempdir().unwrap();
2239        let reply = serde_json::json!({
2240            "data": {"r0": null},
2241            "errors": [{"type": "RATE_LIMITED", "message": "slow down"}]
2242        })
2243        .to_string();
2244        let (bin, _shim) = fake_gh(dir.path(), &reply, 1);
2245        let refs = [item_ref("rust-works/omni-dev", 1)];
2246        assert!(retry_on_etxtbsy(|| fetch_states(&bin, &refs)).is_err());
2247    }
2248
2249    #[test]
2250    fn fetch_states_rejects_an_unrecognised_state() {
2251        let dir = tempfile::tempdir().unwrap();
2252        let (bin, _shim) = fake_gh(dir.path(), &state_reply("DRAFT"), 0);
2253        let refs = [item_ref("rust-works/omni-dev", 1)];
2254        let err = retry_on_etxtbsy(|| fetch_states(&bin, &refs)).unwrap_err();
2255        assert!(err.to_string().contains("unrecognised state"), "{err}");
2256    }
2257
2258    #[test]
2259    fn a_cached_issue_whose_state_is_unchanged_is_served_after_one_shallow_call() {
2260        let dir = tempfile::tempdir().unwrap();
2261        let cache_dir = tempfile::tempdir().unwrap();
2262        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2263        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2264        let refs = [item_ref("rust-works/omni-dev", 1)];
2265        let docs =
2266            retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2267        assert_eq!(docs[0].title, "cached");
2268        assert_eq!(kinds(dir.path()), ["state"]);
2269        assert!(cache.reuse_note().is_some());
2270    }
2271
2272    #[test]
2273    fn a_cached_open_issue_closed_since_is_refetched() {
2274        let dir = tempfile::tempdir().unwrap();
2275        let cache_dir = tempfile::tempdir().unwrap();
2276        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2277        let (bin, _shim) = state_gh(dir.path(), &state_reply("CLOSED"), &issue_reply("CLOSED"));
2278        let refs = [item_ref("rust-works/omni-dev", 1)];
2279        let docs =
2280            retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2281        assert_eq!(
2282            (docs[0].title.as_str(), docs[0].state),
2283            ("fresh", ItemState::Closed)
2284        );
2285        assert_eq!(kinds(dir.path()), ["state", "full"]);
2286        assert!(cache.reuse_note().is_none(), "a refetch is not a reuse");
2287
2288        // The refetched copy was written back.
2289        let later = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
2290        let docs =
2291            retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &later, &refs, None)).unwrap();
2292        assert_eq!(docs[0].state, ItemState::Closed);
2293        assert_eq!(kinds(dir.path()), ["state", "full", "state"]);
2294    }
2295
2296    #[test]
2297    fn a_listed_open_issue_cached_as_closed_is_refetched_without_a_state_call() {
2298        let dir = tempfile::tempdir().unwrap();
2299        let cache_dir = tempfile::tempdir().unwrap();
2300        let cache = cache_holding(cache_dir.path(), ItemState::Closed);
2301        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2302        let refs = [item_ref("rust-works/omni-dev", 1)];
2303        let docs = retry_on_etxtbsy(|| {
2304            fetch_issues_cached_current(&bin, &cache, &refs, Some(ItemState::Open))
2305        })
2306        .unwrap();
2307        assert_eq!(docs[0].state, ItemState::Open);
2308        assert_eq!(kinds(dir.path()), ["full"]);
2309    }
2310
2311    #[test]
2312    fn a_listed_open_issue_cached_as_open_costs_no_call_at_all() {
2313        let dir = tempfile::tempdir().unwrap();
2314        let cache_dir = tempfile::tempdir().unwrap();
2315        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2316        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2317        let refs = [item_ref("rust-works/omni-dev", 1)];
2318        let docs = retry_on_etxtbsy(|| {
2319            fetch_issues_cached_current(&bin, &cache, &refs, Some(ItemState::Open))
2320        })
2321        .unwrap();
2322        assert_eq!(docs[0].title, "cached");
2323        assert!(kinds(dir.path()).is_empty());
2324    }
2325
2326    #[test]
2327    fn a_cold_cache_makes_no_state_call() {
2328        let dir = tempfile::tempdir().unwrap();
2329        let cache_dir = tempfile::tempdir().unwrap();
2330        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
2331        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2332        let refs = [item_ref("rust-works/omni-dev", 1)];
2333        retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2334        assert_eq!(kinds(dir.path()), ["full"]);
2335        // The copy this run stored is as fresh as a fetch: no recheck either.
2336        retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2337        assert_eq!(kinds(dir.path()), ["full"]);
2338    }
2339
2340    #[test]
2341    fn refresh_makes_no_state_call() {
2342        let dir = tempfile::tempdir().unwrap();
2343        let cache_dir = tempfile::tempdir().unwrap();
2344        cache_holding(cache_dir.path(), ItemState::Open);
2345        let refresh = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, true);
2346        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2347        let refs = [item_ref("rust-works/omni-dev", 1)];
2348        retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &refresh, &refs, None)).unwrap();
2349        assert_eq!(kinds(dir.path()), ["full"]);
2350    }
2351
2352    #[test]
2353    fn a_cached_item_github_cannot_find_is_refetched() {
2354        let dir = tempfile::tempdir().unwrap();
2355        let cache_dir = tempfile::tempdir().unwrap();
2356        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2357        let gone = serde_json::json!({
2358            "data": {"r0": {"i0": null}},
2359            "errors": [{"type": "NOT_FOUND", "path": ["r0", "i0"], "message": "nope"}]
2360        })
2361        .to_string();
2362        let (bin, _shim) = state_gh(dir.path(), &gone, &gone);
2363        let refs = [item_ref("rust-works/omni-dev", 1)];
2364        let docs =
2365            retry_on_etxtbsy(|| fetch_items_cached_current(&bin, &cache, &refs, None)).unwrap();
2366        assert!(docs[0].is_none());
2367        assert_eq!(kinds(dir.path()), ["state", "full"]);
2368    }
2369
2370    #[test]
2371    fn fetch_items_cached_current_refetches_a_cached_item_closed_since() {
2372        let dir = tempfile::tempdir().unwrap();
2373        let cache_dir = tempfile::tempdir().unwrap();
2374        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2375        let (bin, _shim) = state_gh(dir.path(), &state_reply("CLOSED"), &issue_reply("CLOSED"));
2376        let refs = [item_ref("rust-works/omni-dev", 1)];
2377        let docs =
2378            retry_on_etxtbsy(|| fetch_items_cached_current(&bin, &cache, &refs, None)).unwrap();
2379        assert_eq!(docs[0].as_ref().unwrap().state, ItemState::Closed);
2380        assert_eq!(kinds(dir.path()), ["state", "full"]);
2381    }
2382
2383    #[test]
2384    fn a_failed_state_recheck_refetches_the_cached_issue_instead_of_failing() {
2385        let dir = tempfile::tempdir().unwrap();
2386        let cache_dir = tempfile::tempdir().unwrap();
2387        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2388        let rate_limited = serde_json::json!({
2389            "data": {"r0": null},
2390            "errors": [{"type": "RATE_LIMITED", "message": "slow down"}]
2391        })
2392        .to_string();
2393        let (bin, _shim) = state_gh(dir.path(), &rate_limited, &issue_reply("CLOSED"));
2394        let refs = [item_ref("rust-works/omni-dev", 1)];
2395        let docs =
2396            retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2397        assert_eq!(
2398            (docs[0].title.as_str(), docs[0].state),
2399            ("fresh", ItemState::Closed)
2400        );
2401        assert_eq!(kinds(dir.path()), ["state", "full"]);
2402    }
2403
2404    #[test]
2405    fn a_state_already_verified_this_run_is_not_rechecked_again() {
2406        let dir = tempfile::tempdir().unwrap();
2407        let cache_dir = tempfile::tempdir().unwrap();
2408        let cache = cache_holding(cache_dir.path(), ItemState::Open);
2409        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2410        let refs = [item_ref("rust-works/omni-dev", 1)];
2411        // The same item as a routed issue, then as a citation.
2412        retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &cache, &refs, None)).unwrap();
2413        let items =
2414            retry_on_etxtbsy(|| fetch_items_cached_current(&bin, &cache, &refs, None)).unwrap();
2415        assert_eq!(items[0].as_ref().unwrap().title, "cached");
2416        assert_eq!(kinds(dir.path()), ["state"]);
2417    }
2418
2419    #[test]
2420    fn a_cached_pull_request_is_not_rechecked_when_fetched_as_an_issue() {
2421        let dir = tempfile::tempdir().unwrap();
2422        let cache_dir = tempfile::tempdir().unwrap();
2423        let cache = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
2424        let mut pr = cached_doc("rust-works/omni-dev", 1);
2425        pr.kind = ItemKind::ChangeRequest;
2426        cache.store(&pr);
2427        let later = IssueCache::new(cache_dir.path().to_path_buf(), DEFAULT_CACHE_TTL, false);
2428        let (bin, _shim) = state_gh(dir.path(), &state_reply("OPEN"), &issue_reply("OPEN"));
2429        let refs = [item_ref("rust-works/omni-dev", 1)];
2430        retry_on_etxtbsy(|| fetch_issues_cached_current(&bin, &later, &refs, None)).unwrap();
2431        assert_eq!(kinds(dir.path()), ["full"]);
2432    }
2433
2434    /// Runs the shim once so its first `execve` (which can hit `ETXTBSY`, and
2435    /// which `auth_scope` would swallow into `None`) is behind us.
2436    ///
2437    /// The result is discarded *outside* the retry: `retry_on_etxtbsy` hands a
2438    /// non-`ETXTBSY` failure (a shim that exits 1 or prints nothing) straight
2439    /// back, but it can only retry an `ETXTBSY` it actually sees, so the error
2440    /// must not be swallowed inside the closure.
2441    fn warm(bin: &Path) {
2442        let _ = retry_on_etxtbsy(|| checked_auth_scope(bin));
2443    }
2444
2445    #[test]
2446    fn auth_scope_digests_the_token_gh_reports_and_never_returns_it() {
2447        let dir = tempfile::tempdir().unwrap();
2448        let (bin, _shim) = fake_gh(dir.path(), "gho_secret_token", 0);
2449        let scope = retry_on_etxtbsy(|| checked_auth_scope(&bin)).unwrap();
2450        assert_eq!(scope, cache::account_scope("gho_secret_token"));
2451        assert!(!scope.contains("secret"), "{scope}");
2452        assert_eq!(auth_scope(&bin), Some(scope));
2453    }
2454
2455    #[test]
2456    fn auth_scope_is_none_when_gh_cannot_say_who_it_is() {
2457        let dir = tempfile::tempdir().unwrap();
2458        assert!(auth_scope(Path::new("/no/such/gh/xyzzy")).is_none());
2459        let (failing, _shim) = fake_gh(dir.path(), "not logged in", 1);
2460        warm(&failing);
2461        assert!(auth_scope(&failing).is_none());
2462    }
2463
2464    #[test]
2465    fn auth_scope_is_none_when_gh_prints_no_token() {
2466        let dir = tempfile::tempdir().unwrap();
2467        let (silent, _shim) = fake_gh(dir.path(), "", 0);
2468        warm(&silent);
2469        assert!(auth_scope(&silent).is_none());
2470    }
2471
2472    #[test]
2473    fn open_cache_is_namespaced_to_the_login_and_sweeps_expired_entries() {
2474        let dir = tempfile::tempdir().unwrap();
2475        let base = tempfile::tempdir().unwrap();
2476        let env = crate::test_support::env::MapEnv::new();
2477        let (bin, _shim) = fake_gh(dir.path(), "token-a", 0);
2478        warm(&bin);
2479        let open = || open_cache(&env, Some(base.path().to_path_buf()), &bin, false);
2480
2481        open().store(&cached_doc("o/r", 1));
2482        let entry = base
2483            .path()
2484            .join("omni-dev/github-issues")
2485            .join(cache::account_scope("token-a"))
2486            .join("o/r/1.json");
2487        assert!(entry.exists(), "{entry:?}");
2488
2489        // A later run, past the TTL, sweeps it on open.
2490        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
2491        std::fs::File::options()
2492            .write(true)
2493            .open(&entry)
2494            .unwrap()
2495            .set_modified(old)
2496            .unwrap();
2497        let _later = open();
2498        assert!(!entry.exists());
2499    }
2500
2501    #[test]
2502    fn open_cache_is_off_without_a_gh_login() {
2503        let base = tempfile::tempdir().unwrap();
2504        let env = crate::test_support::env::MapEnv::new();
2505        let cache = open_cache(
2506            &env,
2507            Some(base.path().to_path_buf()),
2508            Path::new("/no/such/gh/xyzzy"),
2509            false,
2510        );
2511        cache.store(&cached_doc("o/r", 1));
2512        assert_eq!(std::fs::read_dir(base.path()).unwrap().count(), 0);
2513    }
2514
2515    #[test]
2516    fn open_cache_blocking_opens_the_same_cache_from_async() {
2517        let dir = tempfile::tempdir().unwrap();
2518        let base = tempfile::tempdir().unwrap();
2519        let (bin, _shim) = fake_gh(dir.path(), "token-a", 0);
2520        warm(&bin);
2521        let cache = tokio::runtime::Builder::new_current_thread()
2522            .build()
2523            .unwrap()
2524            .block_on(open_cache_blocking(
2525                crate::test_support::env::MapEnv::new(),
2526                Some(base.path().to_path_buf()),
2527                bin,
2528                false,
2529            ))
2530            .unwrap();
2531        cache.store(&cached_doc("o/r", 1));
2532        assert!(base
2533            .path()
2534            .join("omni-dev/github-issues")
2535            .join(cache::account_scope("token-a"))
2536            .join("o/r/1.json")
2537            .exists());
2538    }
2539
2540    #[test]
2541    fn a_disabled_cache_always_calls_gh() {
2542        let dir = tempfile::tempdir().unwrap();
2543        let (bin, _shim) = counting_gh(dir.path(), &one_issue_reply("t"), 0);
2544        let refs = [item_ref("rust-works/omni-dev", 1)];
2545        let cache = IssueCache::disabled();
2546        retry_on_etxtbsy(|| fetch_issues_cached(&bin, &cache, &refs)).unwrap();
2547        retry_on_etxtbsy(|| fetch_items_cached(&bin, &cache, &refs)).unwrap();
2548        assert_eq!(gh_calls(dir.path()), 2);
2549    }
2550}