1use 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
25const MAX_ISSUES_PER_QUERY: usize = 25;
29
30const MAX_COMMENTS: usize = 100;
35
36const GHOST_LOGIN: &str = "ghost";
38
39#[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
49pub 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
131fn 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
142pub 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
175const MAX_OPEN_ISSUES: usize = 1000;
177
178pub 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
214type QueryIndex = HashMap<(usize, usize), ItemRef>;
217
218fn 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; };
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
254fn 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
272fn 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
284fn 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
322fn 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
378fn 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
413fn 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
455pub 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
479fn 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
500fn 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 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
535fn 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
603fn 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
674pub 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 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 #[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 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 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 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 #[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 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 #[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 #[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 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 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 #[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 #[test]
1274 fn fetch_items_keeps_items_in_other_repositories_when_one_is_missing() {
1275 let dir = tempfile::tempdir().unwrap();
1276 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 #[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 #[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 #[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}