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};
17use std::path::Path;
18
19use anyhow::{anyhow, bail, Context, Result};
20use serde_json::Value;
21use tracing::warn;
22
23use crate::provider::{Comment, GitProvider, IssueDoc, ItemKind, ItemRef, ItemState};
24
25/// Most issues resolved by one `gh api graphql` call. Each issue carries up
26/// to [`MAX_COMMENTS`] comment bodies, so an unbounded `--all-open` query on a
27/// large backlog would hit GraphQL's node and response-size limits.
28const MAX_ISSUES_PER_QUERY: usize = 25;
29
30/// Most comments fetched per issue: the **latest** ones, since a decision
31/// comment lands late and is what moves routing most. An issue with more is
32/// warned about, not paginated: the routed input is truncated well before
33/// this many comments would fit anyway.
34const MAX_COMMENTS: usize = 100;
35
36/// The login GitHub reports for a deleted account.
37const GHOST_LOGIN: &str = "ghost";
38
39/// Whether `raw` names an issue only by number (`#N` or `N`), so it needs the
40/// current repository to resolve. Lets callers skip the `gh repo view`
41/// round-trip when every argument is already qualified.
42#[must_use]
43pub fn needs_default_project(raw: &str) -> bool {
44    let raw = raw.trim();
45    let digits = raw.strip_prefix('#').unwrap_or(raw);
46    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
47}
48
49/// Parses one `<ISSUE>` CLI argument into an [`ItemRef`].
50///
51/// Accepts, in order:
52/// - a full GitHub issue URL: `https://github.com/OWNER/REPO/issues/N`
53/// - `owner/repo#N`
54/// - `#N` or bare `N`, resolved against `default_project` (the `-C/--repo`
55///   checkout's own `owner/repo`) — an error naming the missing context if
56///   `default_project` is `None`.
57pub fn parse_issue_arg(raw: &str, default_project: Option<&str>) -> Result<ItemRef> {
58    let raw = raw.trim();
59
60    if let Some(rest) = raw
61        .strip_prefix("https://github.com/")
62        .or_else(|| raw.strip_prefix("http://github.com/"))
63    {
64        let mut parts = rest.splitn(4, '/');
65        let owner = parts.next().filter(|s| !s.is_empty());
66        let repo = parts.next().filter(|s| !s.is_empty());
67        let segment = parts.next();
68        let number_part = parts.next();
69        if let (Some(owner), Some(repo), Some("issues"), Some(number_part)) =
70            (owner, repo, segment, number_part)
71        {
72            let digits: String = number_part
73                .chars()
74                .take_while(char::is_ascii_digit)
75                .collect();
76            let number = digits
77                .parse::<u64>()
78                .map_err(|_| anyhow!("`{raw}` does not end in a valid issue number"))?;
79            return Ok(ItemRef {
80                provider: GitProvider::GitHub,
81                project: format!("{owner}/{repo}"),
82                kind: ItemKind::Issue,
83                number,
84            });
85        }
86        bail!(
87            "`{raw}` is not a recognised GitHub issue URL \
88             (expected https://github.com/OWNER/REPO/issues/N)"
89        );
90    }
91
92    if let Some((project, number_part)) = raw.split_once('#') {
93        if project.contains('/') {
94            if !is_owner_repo(project) {
95                bail!("`{raw}` does not name a repository as owner/repo");
96            }
97            let number = number_part
98                .parse::<u64>()
99                .with_context(|| format!("`{raw}` does not have a valid issue number"))?;
100            return Ok(ItemRef {
101                provider: GitProvider::GitHub,
102                project: project.to_string(),
103                kind: ItemKind::Issue,
104                number,
105            });
106        }
107    }
108
109    let digits = raw.strip_prefix('#').unwrap_or(raw);
110    if let Ok(number) = digits.parse::<u64>() {
111        let project = default_project.ok_or_else(|| {
112            anyhow!(
113                "`{raw}` has no repository; pass owner/repo#{digits}, a full issue URL, \
114                 or run inside a GitHub repository checkout (see -C/--repo)"
115            )
116        })?;
117        return Ok(ItemRef {
118            provider: GitProvider::GitHub,
119            project: project.to_string(),
120            kind: ItemKind::Issue,
121            number,
122        });
123    }
124
125    bail!(
126        "`{raw}` is not a recognised issue reference \
127         (expected #N, N, owner/repo#N, or a GitHub issue URL)"
128    );
129}
130
131/// Whether `project` is exactly `owner/repo`: two non-empty segments with no
132/// whitespace. GitHub has no nested paths, so `a/b/c` would otherwise reach
133/// the query as a repository literally named `b/c`.
134fn is_owner_repo(project: &str) -> bool {
135    let segment_ok =
136        |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(char::is_whitespace);
137    project
138        .split_once('/')
139        .is_some_and(|(owner, name)| segment_ok(owner) && segment_ok(name))
140}
141
142/// Resolves the `owner/repo` of the GitHub repository checked out at `cwd`,
143/// via `gh repo view`.
144pub fn resolve_current_project(bin: &Path, cwd: &Path) -> Result<String> {
145    let output = crate::github_metrics::run_gh(
146        bin,
147        [
148            "repo",
149            "view",
150            "--json",
151            "nameWithOwner",
152            "--jq",
153            ".nameWithOwner",
154        ],
155        "repo view",
156        Some(cwd),
157    )
158    .with_context(|| {
159        format!(
160            "Failed to run {} (is the GitHub CLI installed?)",
161            bin.display()
162        )
163    })?;
164    if !output.status.success() {
165        let stderr = String::from_utf8_lossy(&output.stderr);
166        bail!("gh repo view failed: {}", stderr.trim());
167    }
168    let project = String::from_utf8_lossy(&output.stdout).trim().to_string();
169    if project.is_empty() {
170        bail!("gh repo view returned an empty repository name");
171    }
172    Ok(project)
173}
174
175/// Most issues `--all-open` lists; reaching it is warned about.
176const MAX_OPEN_ISSUES: usize = 1000;
177
178/// Lists every open issue number in `project`, for `--all-open`.
179pub fn list_open_issue_numbers(bin: &Path, project: &str) -> Result<Vec<u64>> {
180    let limit = MAX_OPEN_ISSUES.to_string();
181    let output = crate::github_metrics::run_gh(
182        bin,
183        [
184            "issue", "list", "--state", "open", "--json", "number", "-R", project, "--limit",
185            &limit,
186        ],
187        "issue list",
188        None,
189    )
190    .with_context(|| {
191        format!(
192            "Failed to run {} (is the GitHub CLI installed?)",
193            bin.display()
194        )
195    })?;
196    if !output.status.success() {
197        let stderr = String::from_utf8_lossy(&output.stderr);
198        bail!("gh issue list failed: {}", stderr.trim());
199    }
200    let value: Value =
201        serde_json::from_slice(&output.stdout).context("gh issue list returned invalid JSON")?;
202    let numbers: Vec<u64> = value
203        .as_array()
204        .context("gh issue list returned non-array JSON")?
205        .iter()
206        .filter_map(|v| v.get("number").and_then(Value::as_u64))
207        .collect();
208    if numbers.len() >= MAX_OPEN_ISSUES {
209        warn!("{project} has at least {MAX_OPEN_ISSUES} open issues; only the first {MAX_OPEN_ISSUES} are listed");
210    }
211    Ok(numbers)
212}
213
214/// Maps a query's `(repo alias index, issue alias index)` back to the
215/// [`ItemRef`] it was built for.
216type QueryIndex = HashMap<(usize, usize), ItemRef>;
217
218/// Builds the single aliased query for every ref, grouped by project so each
219/// repository appears once — same shape as
220/// `pr_status::build_query`/`branch_fragment`. Returns `None` for an empty
221/// slice.
222fn build_issue_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
223    if refs.is_empty() {
224        return None;
225    }
226    let mut by_project: BTreeMap<&str, Vec<&ItemRef>> = BTreeMap::new();
227    for item_ref in refs {
228        by_project
229            .entry(item_ref.project.as_str())
230            .or_default()
231            .push(item_ref);
232    }
233    let mut index = HashMap::new();
234    let mut repos = Vec::new();
235    for (ri, (project, items)) in by_project.iter().enumerate() {
236        let Some((owner, name)) = project.split_once('/') else {
237            continue; // validated by parse_issue_arg; defensive skip only
238        };
239        let mut frags = Vec::new();
240        for (ii, item_ref) in items.iter().enumerate() {
241            frags.push(issue_fragment(&format!("i{ii}"), item_ref.number));
242            index.insert((ri, ii), (*item_ref).clone());
243        }
244        let owner = Value::String(owner.to_string());
245        let name = Value::String(name.to_string());
246        repos.push(format!(
247            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
248            frags.join("\n")
249        ));
250    }
251    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
252}
253
254/// The GraphQL fragment resolving one issue: title, body, state, url, its
255/// latest comments (still returned oldest first, so a routed input reads in
256/// conversation order),
257/// and the numbers of the change requests that closed it.
258/// `closedByPullRequestsReferences` asks only for `number` — the closing PRs'
259/// own title/body are out of scope for `route`'s input (the issue explicitly
260/// says not to fold referenced items into the routed text); a later
261/// `verify-decision` cited-source fetch re-queries them.
262fn issue_fragment(alias: &str, number: u64) -> String {
263    format!(
264        r"{alias}: issue(number:{number}){{
265      title body state url
266      comments(last:{MAX_COMMENTS}){{ totalCount nodes{{ databaseId author{{ __typename login }} body }} }}
267      closedByPullRequestsReferences(first:10){{ nodes{{ number }} }}
268    }}"
269    )
270}
271
272/// Whether a comment author is a bot. GraphQL reports a GitHub App author as
273/// `__typename: "Bot"` with the `[bot]` suffix *stripped* from its login
274/// (`github-actions`, not `github-actions[bot]`), so the type is the reliable
275/// signal; the suffix check covers anything that still carries it.
276fn is_bot_author(author: &Value) -> bool {
277    author.get("__typename").and_then(Value::as_str) == Some("Bot")
278        || author
279            .get("login")
280            .and_then(Value::as_str)
281            .is_some_and(|login| login.ends_with("[bot]"))
282}
283
284/// Reads the human comments out of an issue node, warning when the issue has
285/// more than [`MAX_COMMENTS`]. A comment whose author was deleted (`author:
286/// null`) is kept under [`GHOST_LOGIN`], as GitHub's own UI shows it — the
287/// text is still human judgement.
288fn human_comments(item_ref: &ItemRef, node: &Value) -> Vec<Comment> {
289    let comments = node.get("comments");
290    let total = comments
291        .and_then(|c| c.get("totalCount"))
292        .and_then(Value::as_u64)
293        .unwrap_or_default();
294    if total > MAX_COMMENTS as u64 {
295        warn!("Issue {item_ref} has {total} comments; only the latest {MAX_COMMENTS} are used");
296    }
297    comments
298        .and_then(|c| c.get("nodes"))
299        .and_then(Value::as_array)
300        .into_iter()
301        .flatten()
302        .filter_map(|c| {
303            let author = c.get("author").filter(|a| !a.is_null());
304            if author.is_some_and(is_bot_author) {
305                return None;
306            }
307            let login = author
308                .and_then(|a| a.get("login"))
309                .and_then(Value::as_str)
310                .unwrap_or(GHOST_LOGIN);
311            let body = c.get("body")?.as_str()?.to_string();
312            let id = c.get("databaseId").and_then(Value::as_u64);
313            Some(Comment {
314                author: login.to_string(),
315                body,
316                id,
317            })
318        })
319        .collect()
320}
321
322/// Builds one [`IssueDoc`] from its GraphQL node.
323fn build_issue_doc(item_ref: &ItemRef, node: &Value) -> Result<IssueDoc> {
324    let title = node
325        .get("title")
326        .and_then(Value::as_str)
327        .with_context(|| format!("issue {item_ref}: response had no `title`"))?
328        .to_string();
329    let body = node
330        .get("body")
331        .and_then(Value::as_str)
332        .unwrap_or_default()
333        .to_string();
334    let url = node
335        .get("url")
336        .and_then(Value::as_str)
337        .with_context(|| format!("issue {item_ref}: response had no `url`"))?
338        .to_string();
339    let state = match node.get("state").and_then(Value::as_str) {
340        Some("OPEN") => ItemState::Open,
341        Some("CLOSED") => ItemState::Closed,
342        other => bail!("issue {item_ref}: unrecognised state {other:?}"),
343    };
344
345    let comments = human_comments(item_ref, node);
346
347    let closed_by = node
348        .get("closedByPullRequestsReferences")
349        .and_then(|c| c.get("nodes"))
350        .and_then(Value::as_array)
351        .into_iter()
352        .flatten()
353        .filter_map(|pr| {
354            let number = pr.get("number")?.as_u64()?;
355            Some(ItemRef {
356                provider: GitProvider::GitHub,
357                project: item_ref.project.clone(),
358                kind: ItemKind::ChangeRequest,
359                number,
360            })
361        })
362        .collect();
363
364    Ok(IssueDoc {
365        provider: GitProvider::GitHub,
366        project: item_ref.project.clone(),
367        number: item_ref.number,
368        kind: ItemKind::Issue,
369        title,
370        state,
371        body,
372        comments,
373        closed_by,
374        url,
375    })
376}
377
378/// Reads every aliased issue out of a query reply, keyed by `(project,
379/// number)`. Errors, naming the issue, if any aliased node is missing or
380/// null — a fetched ref that doesn't resolve is a hard input error here,
381/// unlike `pr_status`'s tri-state design (there, "no PR" is a valid
382/// successful answer; here, "no such issue" never is).
383fn parse_issue_response(
384    body: &Value,
385    index: &QueryIndex,
386) -> Result<HashMap<(String, u64), IssueDoc>> {
387    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
388        if !errors.is_empty() {
389            bail!("{}", describe_graphql_errors(errors, index));
390        }
391    }
392    let data = body
393        .get("data")
394        .context("gh api graphql response had no `data`")?;
395
396    let mut docs = HashMap::with_capacity(index.len());
397    for ((ri, ii), item_ref) in index {
398        let node = data
399            .get(format!("r{ri}"))
400            .and_then(|r| r.get(format!("i{ii}")))
401            .filter(|v| !v.is_null());
402        let Some(node) = node else {
403            bail!("issue {item_ref} was not found (check the number and that gh has access)");
404        };
405        docs.insert(
406            (item_ref.project.clone(), item_ref.number),
407            build_issue_doc(item_ref, node)?,
408        );
409    }
410    Ok(docs)
411}
412
413/// Turns a GraphQL `errors` array into one message. A `NOT_FOUND` error is
414/// mapped back through its `path` (`["r0", "i1"]`) to the issue or
415/// repository it names — GitHub reports a missing issue, or a pull-request
416/// number asked for as an issue, this way rather than as a `null` node alone.
417/// Anything else is reported by its `message`.
418fn describe_graphql_errors(errors: &[Value], index: &QueryIndex) -> String {
419    let alias = |segment: Option<&Value>, prefix: char| {
420        segment
421            .and_then(Value::as_str)
422            .and_then(|s| s.strip_prefix(prefix))
423            .and_then(|n| n.parse::<usize>().ok())
424    };
425    let described: Vec<String> = errors
426        .iter()
427        .map(|error| {
428            let path = error.get("path").and_then(Value::as_array);
429            let repo = alias(path.and_then(|p| p.first()), 'r');
430            let issue = alias(path.and_then(|p| p.get(1)), 'i');
431            let not_found = error.get("type").and_then(Value::as_str) == Some("NOT_FOUND");
432            match (not_found, repo, issue) {
433                (true, Some(ri), Some(ii)) if index.contains_key(&(ri, ii)) => format!(
434                    "issue {} was not found (check the number, that it is an issue rather \
435                     than a pull request, and that gh has access)",
436                    index[&(ri, ii)]
437                ),
438                (true, Some(ri), None) => {
439                    let project = index
440                        .iter()
441                        .find(|((r, _), _)| *r == ri)
442                        .map_or("?", |(_, item_ref)| item_ref.project.as_str());
443                    format!("repository {project} was not found (or gh has no access to it)")
444                }
445                _ => error
446                    .get("message")
447                    .and_then(Value::as_str)
448                    .map_or_else(|| error.to_string(), str::to_string),
449            }
450        })
451        .collect();
452    format!("gh api graphql failed: {}", described.join("; "))
453}
454
455/// Fetches every ref, returning issues in the same order as `refs`.
456///
457/// Makes one `gh api graphql` call per `MAX_ISSUES_PER_QUERY` refs, each
458/// grouping its refs by repo alias. **Blocking** — callers must be on a
459/// blocking thread.
460pub fn fetch_issues(bin: &Path, refs: &[ItemRef]) -> Result<Vec<IssueDoc>> {
461    let mut by_key = HashMap::with_capacity(refs.len());
462    for chunk in refs.chunks(MAX_ISSUES_PER_QUERY) {
463        let Some((query, index)) = build_issue_query(chunk) else {
464            continue;
465        };
466        let body = crate::pr_status::run_gh_graphql(bin, &query)?;
467        by_key.extend(parse_issue_response(&body, &index)?);
468    }
469    refs.iter()
470        .map(|item_ref| {
471            by_key
472                .get(&(item_ref.project.clone(), item_ref.number))
473                .cloned()
474                .ok_or_else(|| anyhow!("issue {item_ref} missing from the parsed gh reply (bug)"))
475        })
476        .collect()
477}
478
479/// The GraphQL fragment resolving one reference that may be either an issue
480/// or a pull request — used by `verify-decision` to fetch a decision
481/// comment's cited sources, where `#N` is ambiguous until the API answers.
482/// `__typename` decides the [`ItemKind`]; only [`ItemKind::Issue`] carries
483/// comments and closing PRs (a pull request's own body is the whole source).
484fn item_fragment(alias: &str, number: u64) -> String {
485    format!(
486        r"{alias}: issueOrPullRequest(number:{number}){{
487      __typename
488      ... on Issue {{
489        title body state url
490        comments(last:{MAX_COMMENTS}){{ totalCount nodes{{ databaseId author{{ __typename login }} body }} }}
491        closedByPullRequestsReferences(first:10){{ nodes{{ number }} }}
492      }}
493      ... on PullRequest {{
494        title body state url
495      }}
496    }}"
497    )
498}
499
500/// [`build_issue_query`], but for [`item_fragment`] — same repo-grouping and
501/// alias scheme, so [`parse_item_response`] can reuse [`describe_graphql_errors`].
502fn build_item_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
503    if refs.is_empty() {
504        return None;
505    }
506    let mut by_project: BTreeMap<&str, Vec<&ItemRef>> = BTreeMap::new();
507    for item_ref in refs {
508        by_project
509            .entry(item_ref.project.as_str())
510            .or_default()
511            .push(item_ref);
512    }
513    let mut index = HashMap::new();
514    let mut repos = Vec::new();
515    for (ri, (project, items)) in by_project.iter().enumerate() {
516        let Some((owner, name)) = project.split_once('/') else {
517            // omni-dev: coverage ignore-line reason="validated by the citation parser; every ItemRef reaching here already has a project of the form owner/repo"
518            continue;
519        };
520        let mut frags = Vec::new();
521        for (ii, item_ref) in items.iter().enumerate() {
522            frags.push(item_fragment(&format!("i{ii}"), item_ref.number));
523            index.insert((ri, ii), (*item_ref).clone());
524        }
525        let owner = Value::String(owner.to_string());
526        let name = Value::String(name.to_string());
527        repos.push(format!(
528            "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
529            frags.join("\n")
530        ));
531    }
532    Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
533}
534
535/// Builds an [`IssueDoc`] from an `issueOrPullRequest` node, using
536/// `__typename` — not the caller's `item_ref.kind` hint — to decide whether
537/// this is an issue or a change request: a decision comment citing "PR #N"
538/// when `#N` is actually an issue (or vice versa) must be resolved by what
539/// the item *is*, not by how it was written.
540fn build_item_doc(item_ref: &ItemRef, node: &Value) -> Result<IssueDoc> {
541    let kind = match node.get("__typename").and_then(Value::as_str) {
542        Some("Issue") => ItemKind::Issue,
543        Some("PullRequest") => ItemKind::ChangeRequest,
544        other => bail!("item {item_ref}: unrecognised __typename {other:?}"),
545    };
546    let title = node
547        .get("title")
548        .and_then(Value::as_str)
549        .with_context(|| format!("item {item_ref}: response had no `title`"))?
550        .to_string();
551    let body = node
552        .get("body")
553        .and_then(Value::as_str)
554        .unwrap_or_default()
555        .to_string();
556    let url = node
557        .get("url")
558        .and_then(Value::as_str)
559        .with_context(|| format!("item {item_ref}: response had no `url`"))?
560        .to_string();
561    let state = match node.get("state").and_then(Value::as_str) {
562        Some("OPEN") => ItemState::Open,
563        Some("CLOSED" | "MERGED") => ItemState::Closed,
564        other => bail!("item {item_ref}: unrecognised state {other:?}"),
565    };
566    let (comments, closed_by) = if kind == ItemKind::Issue {
567        (
568            human_comments(item_ref, node),
569            node.get("closedByPullRequestsReferences")
570                .and_then(|c| c.get("nodes"))
571                .and_then(Value::as_array)
572                .into_iter()
573                .flatten()
574                .filter_map(|pr| {
575                    let number = pr.get("number")?.as_u64()?;
576                    Some(ItemRef {
577                        provider: GitProvider::GitHub,
578                        project: item_ref.project.clone(),
579                        kind: ItemKind::ChangeRequest,
580                        number,
581                    })
582                })
583                .collect(),
584        )
585    } else {
586        (Vec::new(), Vec::new())
587    };
588
589    Ok(IssueDoc {
590        provider: GitProvider::GitHub,
591        project: item_ref.project.clone(),
592        number: item_ref.number,
593        kind,
594        title,
595        state,
596        body,
597        comments,
598        closed_by,
599        url,
600    })
601}
602
603/// Reads every aliased item out of an [`item_fragment`] query reply.
604///
605/// Unlike [`parse_issue_response`], a `NOT_FOUND` is **tolerated** as
606/// `None` rather than failing the whole batch: a citation can be stale, a
607/// typo, or a quoted example (`owner/repo#123`), and one bad citation must
608/// not stop `route` or `verify-decision` from handling the rest. That covers
609/// both a missing item (path `["rK", "iN"]`) and a missing or inaccessible
610/// repository (path `["rK"]`), which resolves every item cited in it to
611/// `None` (#2001). Any other GraphQL error (a rate limit, a `NOT_FOUND` on a
612/// path this query didn't alias) still fails the batch — retrying it per
613/// item would not help.
614fn parse_item_response(
615    body: &Value,
616    index: &QueryIndex,
617) -> Result<HashMap<(String, u64), Option<IssueDoc>>> {
618    let mut not_found: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
619    let mut missing_repos: std::collections::HashSet<usize> = std::collections::HashSet::new();
620    if let Some(errors) = body.get("errors").and_then(Value::as_array) {
621        for error in errors {
622            let is_not_found = error.get("type").and_then(Value::as_str) == Some("NOT_FOUND");
623            let path = error.get("path").and_then(Value::as_array);
624            let alias = |segment: Option<&Value>, prefix: char| {
625                segment
626                    .and_then(Value::as_str)
627                    .and_then(|s| s.strip_prefix(prefix))
628                    .and_then(|n| n.parse::<usize>().ok())
629            };
630            let ri = alias(path.and_then(|p| p.first()), 'r');
631            let ii = alias(path.and_then(|p| p.get(1)), 'i');
632            match (is_not_found, ri, ii) {
633                (true, Some(ri), Some(ii)) if index.contains_key(&(ri, ii)) => {
634                    not_found.insert((ri, ii));
635                }
636                (true, Some(ri), None) if index.keys().any(|(r, _)| *r == ri) => {
637                    missing_repos.insert(ri);
638                }
639                _ => bail!(
640                    "{}",
641                    describe_graphql_errors(std::slice::from_ref(error), index)
642                ),
643            }
644        }
645    }
646
647    let data = body
648        .get("data")
649        .context("gh api graphql response had no `data`")?;
650
651    let mut docs = HashMap::with_capacity(index.len());
652    for ((ri, ii), item_ref) in index {
653        let key = (item_ref.project.clone(), item_ref.number);
654        if missing_repos.contains(ri) || not_found.contains(&(*ri, *ii)) {
655            docs.insert(key, None);
656            continue;
657        }
658        let node = data
659            .get(format!("r{ri}"))
660            .and_then(|r| r.get(format!("i{ii}")))
661            .filter(|v| !v.is_null());
662        match node {
663            Some(node) => {
664                docs.insert(key, Some(build_item_doc(item_ref, node)?));
665            }
666            None => {
667                docs.insert(key, None);
668            }
669        }
670    }
671    Ok(docs)
672}
673
674/// Fetches a set of references that may be issues or pull requests.
675///
676/// Tolerates a per-reference "not found" (`None`) rather than failing the
677/// whole call, including a reference into a repository GitHub reports as
678/// `NOT_FOUND` — used by `route` and `verify-decision` to resolve citations.
679/// Any other error (e.g. an SSO-gated org's `FORBIDDEN`) still fails it. Returns one entry per `refs`, in the same order.
680/// **Blocking** — callers must be on a blocking thread.
681pub fn fetch_items(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<IssueDoc>>> {
682    let mut by_key = HashMap::with_capacity(refs.len());
683    for chunk in refs.chunks(MAX_ISSUES_PER_QUERY) {
684        let Some((query, index)) = build_item_query(chunk) else {
685            // omni-dev: coverage ignore-line reason="chunks() never yields an empty chunk from a non-empty refs slice, and build_item_query returns None only for an empty slice"
686            continue;
687        };
688        let body = crate::pr_status::run_gh_graphql_with_partial_data(bin, &query)?;
689        by_key.extend(parse_item_response(&body, &index)?);
690    }
691    refs.iter()
692        .map(|item_ref| {
693            by_key
694                .get(&(item_ref.project.clone(), item_ref.number))
695                .cloned()
696                .ok_or_else(|| anyhow!("item {item_ref} missing from the parsed gh reply (bug)"))
697        })
698        .collect()
699}
700
701#[cfg(test)]
702#[allow(clippy::unwrap_used, clippy::expect_used)]
703mod tests {
704    use super::*;
705    use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
706    use std::path::PathBuf;
707    use std::sync::MutexGuard;
708
709    // ── parse_issue_arg ─────────────────────────────────────────────
710
711    #[test]
712    fn parses_bare_hash_number_with_default_project() {
713        let item_ref = parse_issue_arg("#1779", Some("rust-works/omni-dev")).unwrap();
714        assert_eq!(item_ref.project, "rust-works/omni-dev");
715        assert_eq!(item_ref.number, 1779);
716        assert_eq!(item_ref.kind, ItemKind::Issue);
717    }
718
719    #[test]
720    fn parses_bare_number_with_default_project() {
721        let item_ref = parse_issue_arg("1779", Some("rust-works/omni-dev")).unwrap();
722        assert_eq!(item_ref.number, 1779);
723    }
724
725    #[test]
726    fn bare_number_without_default_project_errors() {
727        let err = parse_issue_arg("1779", None).unwrap_err();
728        assert!(err.to_string().contains("has no repository"));
729    }
730
731    #[test]
732    fn parses_owner_repo_hash_number() {
733        let item_ref = parse_issue_arg("rust-works/omni-dev#1779", None).unwrap();
734        assert_eq!(item_ref.project, "rust-works/omni-dev");
735        assert_eq!(item_ref.number, 1779);
736    }
737
738    #[test]
739    fn owner_repo_hash_number_ignores_default_project() {
740        let item_ref = parse_issue_arg("other/repo#42", Some("rust-works/omni-dev")).unwrap();
741        assert_eq!(item_ref.project, "other/repo");
742        assert_eq!(item_ref.number, 42);
743    }
744
745    #[test]
746    fn parses_full_issue_url() {
747        let item_ref =
748            parse_issue_arg("https://github.com/rust-works/omni-dev/issues/1779", None).unwrap();
749        assert_eq!(item_ref.project, "rust-works/omni-dev");
750        assert_eq!(item_ref.number, 1779);
751    }
752
753    #[test]
754    fn full_issue_url_ignores_trailing_content() {
755        let item_ref = parse_issue_arg(
756            "https://github.com/rust-works/omni-dev/issues/1779?tab=comments",
757            None,
758        )
759        .unwrap();
760        assert_eq!(item_ref.number, 1779);
761    }
762
763    #[test]
764    fn rejects_a_pull_request_url() {
765        let err =
766            parse_issue_arg("https://github.com/rust-works/omni-dev/pull/1779", None).unwrap_err();
767        assert!(err
768            .to_string()
769            .contains("not a recognised GitHub issue URL"));
770    }
771
772    #[test]
773    fn rejects_garbage_input() {
774        let err = parse_issue_arg("not an issue", None).unwrap_err();
775        assert!(err.to_string().contains("not a recognised issue reference"));
776    }
777
778    #[test]
779    fn rejects_a_nested_or_empty_project() {
780        for raw in ["a/b/c#1", "/repo#1", "owner/#1", "own er/repo#1"] {
781            let err = parse_issue_arg(raw, None).unwrap_err();
782            assert!(err.to_string().contains("owner/repo"), "{raw}: {err}");
783        }
784    }
785
786    #[test]
787    fn rejects_non_numeric_hash_number() {
788        let err = parse_issue_arg("owner/repo#abc", None).unwrap_err();
789        assert!(err.to_string().contains("valid issue number"));
790    }
791
792    // ── build_issue_query ───────────────────────────────────────────
793
794    fn item_ref(project: &str, number: u64) -> ItemRef {
795        ItemRef {
796            provider: GitProvider::GitHub,
797            project: project.to_string(),
798            kind: ItemKind::Issue,
799            number,
800        }
801    }
802
803    #[test]
804    fn build_issue_query_is_none_for_no_refs() {
805        assert!(build_issue_query(&[]).is_none());
806    }
807
808    #[test]
809    fn build_item_query_is_none_for_no_refs() {
810        assert!(build_item_query(&[]).is_none());
811    }
812
813    #[test]
814    fn build_issue_query_groups_by_project() {
815        let (query, index) = build_issue_query(&[
816            item_ref("rust-works/omni-dev", 1),
817            item_ref("rust-works/omni-dev", 2),
818            item_ref("other/repo", 3),
819        ])
820        .unwrap();
821        assert_eq!(query.matches("repository(owner:").count(), 2);
822        assert_eq!(index.len(), 3);
823    }
824
825    // ── fetch_issues (fake-gh shim) ──────────────────────────────────
826
827    fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
828        let guard = shim_lock();
829        let path = dir.join("fake-gh");
830        write_exec_script(
831            &path,
832            &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
833        );
834        (path, guard)
835    }
836
837    #[test]
838    fn fetch_issues_empty_refs_runs_nothing() {
839        let docs = fetch_issues(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
840        assert!(docs.is_empty());
841    }
842
843    #[test]
844    fn fetch_issues_parses_a_single_issue() {
845        let dir = tempfile::tempdir().unwrap();
846        let (bin, _shim) = fake_gh(
847            dir.path(),
848            &serde_json::json!({
849                "data": {
850                    "r0": {
851                        "i0": {
852                            "title": "Route issues by stage",
853                            "body": "Body text",
854                            "state": "OPEN",
855                            "url": "https://github.com/rust-works/omni-dev/issues/1779",
856                            "comments": {"nodes": [
857                                {"author": {"login": "newhoggy"}, "body": "A human comment"},
858                                {"author": {"login": "github-actions[bot]"}, "body": "drift report"}
859                            ]},
860                            "closedByPullRequestsReferences": {"nodes": []}
861                        }
862                    }
863                }
864            })
865            .to_string(),
866            0,
867        );
868        let docs =
869            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1779)]))
870                .unwrap();
871        assert_eq!(docs.len(), 1);
872        let doc = &docs[0];
873        assert_eq!(doc.title, "Route issues by stage");
874        assert_eq!(doc.state, ItemState::Open);
875        assert_eq!(doc.comments.len(), 1);
876        assert_eq!(doc.comments[0].author, "newhoggy");
877    }
878
879    #[test]
880    fn fetch_issues_preserves_caller_order_across_projects() {
881        let dir = tempfile::tempdir().unwrap();
882        let issue = |title: &str| {
883            serde_json::json!({
884                "title": title, "body": "", "state": "OPEN",
885                "url": "https://example.com", "comments": {"nodes": []},
886                "closedByPullRequestsReferences": {"nodes": []}
887            })
888        };
889        let (bin, _shim) = fake_gh(
890            dir.path(),
891            &serde_json::json!({
892                "data": {
893                    "r0": {"i0": issue("second")},
894                    "r1": {"i0": issue("first")},
895                }
896            })
897            .to_string(),
898            0,
899        );
900        // Alphabetical project grouping puts "other/repo" (r0) before
901        // "rust-works/omni-dev" (r1), opposite the caller's requested order.
902        let refs = [
903            item_ref("rust-works/omni-dev", 1),
904            item_ref("other/repo", 2),
905        ];
906        let docs = retry_on_etxtbsy(|| fetch_issues(&bin, &refs)).unwrap();
907        assert_eq!(docs[0].title, "first");
908        assert_eq!(docs[1].title, "second");
909    }
910
911    #[test]
912    fn fetch_issues_errors_on_missing_issue() {
913        let dir = tempfile::tempdir().unwrap();
914        let (bin, _shim) = fake_gh(
915            dir.path(),
916            &serde_json::json!({"data": {"r0": {"i0": null}}}).to_string(),
917            0,
918        );
919        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
920            .unwrap_err();
921        assert!(err.to_string().contains("was not found"));
922    }
923
924    #[test]
925    fn fetch_issues_errors_on_graphql_errors_array() {
926        let dir = tempfile::tempdir().unwrap();
927        let (bin, _shim) = fake_gh(
928            dir.path(),
929            &serde_json::json!({"errors": [{"message": "Something broke"}]}).to_string(),
930            0,
931        );
932        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
933            .unwrap_err();
934        assert_eq!(err.to_string(), "gh api graphql failed: Something broke");
935    }
936
937    /// The real API's shape for a missing issue: a `NOT_FOUND` error with a
938    /// path, next to a `null` node.
939    #[test]
940    fn fetch_issues_names_a_missing_issue_from_a_not_found_error() {
941        let dir = tempfile::tempdir().unwrap();
942        let (bin, _shim) = fake_gh(
943            dir.path(),
944            &serde_json::json!({
945                "data": {"r0": {"i0": null}},
946                "errors": [{
947                    "type": "NOT_FOUND", "path": ["r0", "i0"],
948                    "message": "Could not resolve to an Issue with the number of 9999."
949                }]
950            })
951            .to_string(),
952            0,
953        );
954        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 9999)]))
955            .unwrap_err();
956        let msg = err.to_string();
957        assert!(
958            msg.contains("issue rust-works/omni-dev#9999 was not found"),
959            "{msg}"
960        );
961        assert!(msg.contains("pull request"), "{msg}");
962    }
963
964    #[test]
965    fn fetch_issues_names_a_missing_repository() {
966        let dir = tempfile::tempdir().unwrap();
967        let (bin, _shim) = fake_gh(
968            dir.path(),
969            &serde_json::json!({
970                "data": {"r0": null},
971                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
972            })
973            .to_string(),
974            0,
975        );
976        let err = retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("no/such", 1)])).unwrap_err();
977        assert!(
978            err.to_string().contains("repository no/such was not found"),
979            "{err}"
980        );
981    }
982
983    #[test]
984    fn fetch_issues_filters_bot_comments() {
985        let dir = tempfile::tempdir().unwrap();
986        let (bin, _shim) = fake_gh(
987            dir.path(),
988            &serde_json::json!({
989                "data": {"r0": {"i0": {
990                    "title": "t", "body": "b", "state": "CLOSED",
991                    "url": "https://example.com",
992                    "comments": {"nodes": [
993                        {"author": {"login": "dependabot[bot]"}, "body": "bump"},
994                        {"author": {"login": "human"}, "body": "hi"}
995                    ]},
996                    "closedByPullRequestsReferences": {"nodes": [{"number": 42}]}
997                }}}
998            })
999            .to_string(),
1000            0,
1001        );
1002        let docs =
1003            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1)])).unwrap();
1004        assert_eq!(docs[0].comments.len(), 1);
1005        assert_eq!(docs[0].comments[0].author, "human");
1006        assert_eq!(docs[0].state, ItemState::Closed);
1007        assert_eq!(docs[0].closed_by.len(), 1);
1008        assert_eq!(docs[0].closed_by[0].number, 42);
1009        assert_eq!(docs[0].closed_by[0].kind, ItemKind::ChangeRequest);
1010    }
1011
1012    #[test]
1013    fn fetch_issues_filters_bots_by_typename_and_keeps_deleted_authors() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let (bin, _shim) = fake_gh(
1016            dir.path(),
1017            &serde_json::json!({
1018                "data": {"r0": {"i0": {
1019                    "title": "t", "body": "b", "state": "OPEN",
1020                    "url": "https://example.com",
1021                    "comments": {"totalCount": 3, "nodes": [
1022                        {"author": {"__typename": "Bot", "login": "github-actions"}, "body": "drift"},
1023                        {"author": null, "body": "from a deleted account"},
1024                        {"author": {"__typename": "User", "login": "human"}, "body": "hi"}
1025                    ]},
1026                    "closedByPullRequestsReferences": {"nodes": []}
1027                }}}
1028            })
1029            .to_string(),
1030            0,
1031        );
1032        let docs =
1033            retry_on_etxtbsy(|| fetch_issues(&bin, &[item_ref("rust-works/omni-dev", 1)])).unwrap();
1034        let authors: Vec<&str> = docs[0].comments.iter().map(|c| c.author.as_str()).collect();
1035        assert_eq!(authors, ["ghost", "human"]);
1036    }
1037
1038    #[test]
1039    fn fetch_issues_splits_large_batches_across_queries() {
1040        let dir = tempfile::tempdir().unwrap();
1041        let issue = serde_json::json!({
1042            "title": "t", "body": "", "state": "OPEN", "url": "https://example.com",
1043            "comments": {"totalCount": 0, "nodes": []},
1044            "closedByPullRequestsReferences": {"nodes": []}
1045        });
1046        // One canned reply answers every chunk: each chunk is a single
1047        // project, so its aliases are r0/i0..i{n}.
1048        let repo: serde_json::Map<String, Value> = (0..MAX_ISSUES_PER_QUERY)
1049            .map(|i| (format!("i{i}"), issue.clone()))
1050            .collect();
1051        let (bin, _shim) = fake_gh(
1052            dir.path(),
1053            &serde_json::json!({"data": {"r0": repo}}).to_string(),
1054            0,
1055        );
1056        let refs: Vec<ItemRef> = (1..=MAX_ISSUES_PER_QUERY as u64 + 1)
1057            .map(|n| item_ref("rust-works/omni-dev", n))
1058            .collect();
1059        let docs = retry_on_etxtbsy(|| fetch_issues(&bin, &refs)).unwrap();
1060        assert_eq!(docs.len(), refs.len());
1061        assert_eq!(docs.last().unwrap().number, MAX_ISSUES_PER_QUERY as u64 + 1);
1062    }
1063
1064    #[test]
1065    fn issue_fragment_fetches_latest_comments_with_author_type() {
1066        let fragment = issue_fragment("i0", 7);
1067        assert!(fragment.contains(&format!("comments(last:{MAX_COMMENTS})")));
1068        assert!(fragment.contains("totalCount"));
1069        assert!(fragment.contains("__typename"));
1070    }
1071
1072    // ── needs_default_project ────────────────────────────────────────
1073
1074    #[test]
1075    fn needs_default_project_only_for_bare_numbers() {
1076        assert!(needs_default_project("#12"));
1077        assert!(needs_default_project(" 12 "));
1078        assert!(!needs_default_project("owner/repo#12"));
1079        assert!(!needs_default_project(
1080            "https://github.com/rust-works/omni-dev/issues/12"
1081        ));
1082        assert!(!needs_default_project("#"));
1083        assert!(!needs_default_project("abc"));
1084    }
1085
1086    // ── resolve_current_project / list_open_issue_numbers ────────────
1087
1088    #[test]
1089    fn resolve_current_project_errors_when_gh_is_missing() {
1090        let err =
1091            resolve_current_project(Path::new("/no/such/gh/xyzzy"), Path::new(".")).unwrap_err();
1092        assert!(err.to_string().contains("Failed to run"));
1093    }
1094
1095    #[test]
1096    fn resolve_current_project_parses_stdout() {
1097        let dir = tempfile::tempdir().unwrap();
1098        let (bin, _shim) = fake_gh(dir.path(), "rust-works/omni-dev", 0);
1099        let project = retry_on_etxtbsy(|| resolve_current_project(&bin, dir.path())).unwrap();
1100        assert_eq!(project, "rust-works/omni-dev");
1101    }
1102
1103    #[test]
1104    fn resolve_current_project_errors_on_nonzero_exit() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1107        let err = retry_on_etxtbsy(|| resolve_current_project(&bin, dir.path())).unwrap_err();
1108        assert!(err.to_string().contains("gh repo view failed"));
1109    }
1110
1111    #[test]
1112    fn list_open_issue_numbers_parses_numbers() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let (bin, _shim) = fake_gh(
1115            dir.path(),
1116            &serde_json::json!([{"number": 1}, {"number": 2}]).to_string(),
1117            0,
1118        );
1119        let numbers =
1120            retry_on_etxtbsy(|| list_open_issue_numbers(&bin, "rust-works/omni-dev")).unwrap();
1121        assert_eq!(numbers, vec![1, 2]);
1122    }
1123
1124    #[test]
1125    fn list_open_issue_numbers_errors_when_gh_is_missing() {
1126        let err = list_open_issue_numbers(Path::new("/no/such/gh/xyzzy"), "rust-works/omni-dev")
1127            .unwrap_err();
1128        assert!(err.to_string().contains("Failed to run"));
1129    }
1130
1131    #[test]
1132    fn list_open_issue_numbers_errors_on_nonzero_exit() {
1133        let dir = tempfile::tempdir().unwrap();
1134        let (bin, _shim) = fake_gh(dir.path(), "", 1);
1135        let err =
1136            retry_on_etxtbsy(|| list_open_issue_numbers(&bin, "rust-works/omni-dev")).unwrap_err();
1137        assert!(err.to_string().contains("gh issue list failed"));
1138    }
1139
1140    // ── fetch_items (fake-gh shim) ────────────────────────────────────
1141
1142    fn pr_ref(project: &str, number: u64) -> ItemRef {
1143        ItemRef {
1144            provider: GitProvider::GitHub,
1145            project: project.to_string(),
1146            kind: ItemKind::ChangeRequest,
1147            number,
1148        }
1149    }
1150
1151    #[test]
1152    fn fetch_items_resolves_an_issue_by_typename() {
1153        let dir = tempfile::tempdir().unwrap();
1154        let (bin, _shim) = fake_gh(
1155            dir.path(),
1156            &serde_json::json!({
1157                "data": {"r0": {"i0": {
1158                    "__typename": "Issue",
1159                    "title": "t", "body": "b", "state": "OPEN", "url": "u",
1160                    "comments": {"totalCount": 1, "nodes": [
1161                        {"databaseId": 42, "author": {"login": "human"}, "body": "hi"}
1162                    ]},
1163                    "closedByPullRequestsReferences": {"nodes": [{"number": 7}]}
1164                }}}
1165            })
1166            .to_string(),
1167            0,
1168        );
1169        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1614)]))
1170            .unwrap();
1171        let doc = docs[0].as_ref().unwrap();
1172        assert_eq!(doc.kind, ItemKind::Issue);
1173        assert_eq!(doc.comments[0].id, Some(42));
1174        assert_eq!(doc.closed_by[0].number, 7);
1175        assert_eq!(doc.closed_by[0].kind, ItemKind::ChangeRequest);
1176    }
1177
1178    #[test]
1179    fn fetch_items_resolves_a_pull_request_by_typename_even_when_cited_as_an_issue() {
1180        let dir = tempfile::tempdir().unwrap();
1181        let (bin, _shim) = fake_gh(
1182            dir.path(),
1183            &serde_json::json!({
1184                "data": {"r0": {"i0": {
1185                    "__typename": "PullRequest",
1186                    "title": "t", "body": "b", "state": "MERGED", "url": "u"
1187                }}}
1188            })
1189            .to_string(),
1190            0,
1191        );
1192        // Cited with an Issue kind hint (e.g. "#1629"); __typename overrides it.
1193        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1629)]))
1194            .unwrap();
1195        let doc = docs[0].as_ref().unwrap();
1196        assert_eq!(doc.kind, ItemKind::ChangeRequest);
1197        assert_eq!(doc.state, ItemState::Closed);
1198        assert!(doc.comments.is_empty());
1199    }
1200
1201    #[test]
1202    fn fetch_items_tolerates_a_not_found_reference() {
1203        let dir = tempfile::tempdir().unwrap();
1204        let (bin, _shim) = fake_gh(
1205            dir.path(),
1206            &serde_json::json!({
1207                "data": {"r0": {"i0": null}},
1208                "errors": [{
1209                    "type": "NOT_FOUND", "path": ["r0", "i0"],
1210                    "message": "Could not resolve to an issue or pull request."
1211                }]
1212            })
1213            .to_string(),
1214            0,
1215        );
1216        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[pr_ref("rust-works/omni-dev", 99999)]))
1217            .unwrap();
1218        assert!(docs[0].is_none());
1219    }
1220
1221    #[test]
1222    fn fetch_items_keeps_valid_items_when_gh_exits_nonzero_for_one_missing_item() {
1223        let dir = tempfile::tempdir().unwrap();
1224        let (bin, _shim) = fake_gh(
1225            dir.path(),
1226            &serde_json::json!({
1227                "data": {"r0": {
1228                    "i0": null,
1229                    "i1": {
1230                        "__typename": "Issue",
1231                        "title": "Valid", "body": "b",
1232                        "state": "OPEN", "url": "u"
1233                    }
1234                }},
1235                "errors": [{
1236                    "type": "NOT_FOUND", "path": ["r0", "i0"],
1237                    "message": "Could not resolve to an issue or pull request."
1238                }]
1239            })
1240            .to_string(),
1241            1,
1242        );
1243        let refs = [
1244            item_ref("rust-works/omni-dev", 2063),
1245            item_ref("rust-works/omni-dev", 1871),
1246        ];
1247        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1248        assert!(docs[0].is_none());
1249        assert_eq!(docs[1].as_ref().unwrap().title, "Valid");
1250    }
1251
1252    /// #2001: a repository-level `NOT_FOUND` (a quoted `owner/repo#123`)
1253    /// resolves every item cited in that repository to `None`.
1254    #[test]
1255    fn fetch_items_tolerates_a_missing_repository() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let (bin, _shim) = fake_gh(
1258            dir.path(),
1259            &serde_json::json!({
1260                "data": {"r0": null},
1261                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
1262            })
1263            .to_string(),
1264            1,
1265        );
1266        let refs = [pr_ref("no/such", 1), item_ref("no/such", 2)];
1267        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1268        assert!(docs.iter().all(Option::is_none), "{docs:?}");
1269    }
1270
1271    /// A missing repository doesn't take down items in a repository that
1272    /// exists: the partial-data reply's valid node still resolves.
1273    #[test]
1274    fn fetch_items_keeps_items_in_other_repositories_when_one_is_missing() {
1275        let dir = tempfile::tempdir().unwrap();
1276        // `build_item_query` aliases projects in sorted order, so `no/such`
1277        // is `r0` and `rust-works/omni-dev` is `r1`.
1278        let (bin, _shim) = fake_gh(
1279            dir.path(),
1280            &serde_json::json!({
1281                "data": {
1282                    "r0": null,
1283                    "r1": {"i0": {
1284                        "__typename": "Issue",
1285                        "title": "Valid", "body": "b",
1286                        "state": "OPEN", "url": "u"
1287                    }}
1288                },
1289                "errors": [{"type": "NOT_FOUND", "path": ["r0"], "message": "Could not resolve"}]
1290            })
1291            .to_string(),
1292            1,
1293        );
1294        let refs = [
1295            item_ref("rust-works/omni-dev", 1871),
1296            item_ref("no/such", 123),
1297        ];
1298        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &refs)).unwrap();
1299        assert_eq!(docs[0].as_ref().unwrap().title, "Valid");
1300        assert!(docs[1].is_none());
1301    }
1302
1303    /// A repository-level `NOT_FOUND` on an alias this query never issued
1304    /// isn't a citation miss we can attribute, so it still fails the batch.
1305    #[test]
1306    fn fetch_items_bails_on_a_not_found_for_an_unknown_repository_alias() {
1307        let dir = tempfile::tempdir().unwrap();
1308        let (bin, _shim) = fake_gh(
1309            dir.path(),
1310            &serde_json::json!({
1311                "data": {"r0": {"i0": null}},
1312                "errors": [{"type": "NOT_FOUND", "path": ["r7"], "message": "Could not resolve"}]
1313            })
1314            .to_string(),
1315            1,
1316        );
1317        let err = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1)]))
1318            .unwrap_err();
1319        assert!(err.to_string().contains("was not found"), "{err}");
1320    }
1321
1322    #[test]
1323    fn item_fragment_uses_the_polymorphic_field() {
1324        let fragment = item_fragment("i0", 42);
1325        assert!(fragment.contains("issueOrPullRequest(number:42)"));
1326        assert!(fragment.contains("__typename"));
1327        assert!(fragment.contains("... on Issue"));
1328        assert!(fragment.contains("... on PullRequest"));
1329    }
1330
1331    // ── build_item_doc ─────────────────────────────────────────────────
1332
1333    #[test]
1334    fn build_item_doc_rejects_an_unrecognised_typename() {
1335        let node = serde_json::json!({
1336            "__typename": "Gist", "title": "t", "body": "b", "state": "OPEN", "url": "u"
1337        });
1338        let err = build_item_doc(&item_ref("rust-works/omni-dev", 1), &node).unwrap_err();
1339        assert!(err.to_string().contains("unrecognised __typename"), "{err}");
1340    }
1341
1342    #[test]
1343    fn build_item_doc_rejects_an_unrecognised_state() {
1344        let node = serde_json::json!({
1345            "__typename": "Issue", "title": "t", "body": "b", "state": "DRAFT", "url": "u"
1346        });
1347        let err = build_item_doc(&item_ref("rust-works/omni-dev", 1), &node).unwrap_err();
1348        assert!(err.to_string().contains("unrecognised state"), "{err}");
1349    }
1350
1351    /// A node missing from the reply with no accompanying GraphQL error
1352    /// (rather than an explicit `NOT_FOUND`) is still treated as not found.
1353    #[test]
1354    fn fetch_items_treats_a_silently_missing_node_as_not_found() {
1355        let dir = tempfile::tempdir().unwrap();
1356        let (bin, _shim) = fake_gh(
1357            dir.path(),
1358            &serde_json::json!({"data": {"r0": {"i0": null}}}).to_string(),
1359            0,
1360        );
1361        let docs = retry_on_etxtbsy(|| fetch_items(&bin, &[item_ref("rust-works/omni-dev", 1614)]))
1362            .unwrap();
1363        assert!(docs[0].is_none());
1364    }
1365}