1use 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
31const MAX_ISSUES_PER_QUERY: usize = 25;
35
36const MAX_COMMENTS: usize = 100;
41
42const GHOST_LOGIN: &str = "ghost";
44
45#[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
55pub 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
137fn 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
148pub 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
181const MAX_OPEN_ISSUES: usize = 1000;
183
184pub 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
220type QueryIndex = HashMap<(usize, usize), ItemRef>;
223
224fn 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; };
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
260fn 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
278fn 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
290fn 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
328fn 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
384fn 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
419fn 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
461pub 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
485fn 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
506fn build_item_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
509 build_aliased_query(refs, item_fragment)
510}
511
512fn 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
524fn build_state_query(refs: &[ItemRef]) -> Option<(String, QueryIndex)> {
526 build_aliased_query(refs, state_fragment)
527}
528
529fn 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
558fn 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 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
597fn 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
607fn 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
671type NotFoundSlots = (HashSet<(usize, usize)>, HashSet<usize>);
673
674fn 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
711fn 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
727fn 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
736fn 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
771pub fn fetch_items(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<IssueDoc>>> {
779 fetch_aliased(bin, refs, build_item_query, parse_item_response)
780}
781
782pub fn fetch_states(bin: &Path, refs: &[ItemRef]) -> Result<Vec<Option<ItemState>>> {
790 fetch_aliased(bin, refs, build_state_query, parse_state_response)
791}
792
793pub 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
815type Parsed<T> = HashMap<(String, u64), T>;
817
818fn 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
841pub 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
854pub 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
888pub 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
904pub 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
914pub 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
944enum StateCheck {
946 Listed(ItemState),
948 Rechecked(HashMap<(String, u64), Option<ItemState>>),
952}
953
954impl StateCheck {
955 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 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
1013fn 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#[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
1062fn 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#[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
1097pub 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
1113fn 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 #[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 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 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 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 #[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 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 #[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 #[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 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 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 #[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 #[test]
1695 fn fetch_items_keeps_items_in_other_repositories_when_one_is_missing() {
1696 let dir = tempfile::tempdir().unwrap();
1697 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 #[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 #[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 #[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 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 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 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 #[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 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 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 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 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 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 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 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 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}