1use std::collections::{BTreeMap, HashMap};
44use std::path::{Path, PathBuf};
45use std::process::Command;
46use std::sync::{Mutex, PoisonError};
47
48use anyhow::{bail, Context, Result};
49use serde::Serialize;
50use serde_json::Value;
51
52const GH_BIN_ENV: &str = "OMNI_DEV_GH_BIN";
57
58const GH_BINARY_CANDIDATES: &[&str] = &[
61 "/opt/homebrew/bin/gh",
62 "/usr/local/bin/gh",
63 "/home/linuxbrew/.linuxbrew/bin/gh",
64 "/usr/bin/gh",
65];
66
67const FAILURE_STATES: &[&str] = &[
70 "FAILURE",
71 "ERROR",
72 "CANCELLED",
73 "TIMED_OUT",
74 "ACTION_REQUIRED",
75 "STARTUP_FAILURE",
76 "STALE",
77];
78
79const SUCCESS_STATES: &[&str] = &["SUCCESS", "NEUTRAL", "SKIPPED"];
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "lowercase")]
86pub enum PrCheckState {
87 Success,
89 Failure,
91 Pending,
94 None,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct PrBadge {
107 pub number: u64,
109 #[serde(rename = "isDraft")]
111 pub is_draft: bool,
112 pub checks: PrCheckState,
114 pub url: String,
116 #[serde(skip)]
124 pub head_oid: String,
125}
126
127impl PrBadge {
128 #[must_use]
141 pub fn is_stale_for(&self, head_sha: Option<&str>) -> bool {
142 head_sha.is_some_and(|sha| sha != self.head_oid)
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
149pub struct PrTarget {
150 pub owner: String,
152 pub name: String,
154 pub branch: String,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166enum EntryState {
167 Failure,
168 Pending,
169 Success,
170}
171
172fn check_entry_state(entry: &Value) -> EntryState {
179 let status = entry.get("status").and_then(Value::as_str).unwrap_or("");
180 if !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED") {
181 return EntryState::Pending;
182 }
183 let raw = entry
187 .get("conclusion")
188 .and_then(Value::as_str)
189 .filter(|s| !s.is_empty())
190 .or_else(|| {
191 entry
192 .get("state")
193 .and_then(Value::as_str)
194 .filter(|s| !s.is_empty())
195 })
196 .unwrap_or("")
197 .to_ascii_uppercase();
198 if FAILURE_STATES.contains(&raw.as_str()) {
199 return EntryState::Failure;
200 }
201 if SUCCESS_STATES.contains(&raw.as_str()) {
202 return EntryState::Success;
203 }
204 EntryState::Pending
205}
206
207fn rollup_check_state(contexts: &[Value]) -> PrCheckState {
212 if contexts.is_empty() {
213 return PrCheckState::None;
214 }
215 let mut saw_pending = false;
216 for entry in contexts {
217 match check_entry_state(entry) {
218 EntryState::Failure => return PrCheckState::Failure,
219 EntryState::Pending => saw_pending = true,
220 EntryState::Success => {}
221 }
222 }
223 if saw_pending {
224 return PrCheckState::Pending;
225 }
226 if contexts.iter().any(suite_still_running) {
229 return PrCheckState::Pending;
230 }
231 PrCheckState::Success
235}
236
237fn suite_still_running(entry: &Value) -> bool {
253 entry
254 .get("checkSuite")
255 .and_then(|suite| suite.get("status"))
256 .and_then(Value::as_str)
257 .is_some_and(|status| !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED"))
258}
259
260fn branch_fragment(alias: &str, branch: &str) -> String {
270 let qualified = Value::String(format!("refs/heads/{branch}"));
273 format!(
274 r"{alias}: ref(qualifiedName:{qualified}){{
275 target{{ ...on Commit{{ oid
276 statusCheckRollup{{ contexts(first:100){{ nodes{{
277 __typename
278 ...on CheckRun{{ status conclusion checkSuite{{ status }} }}
279 ...on StatusContext{{ state }}
280 }} }} }}
281 }} }}
282 associatedPullRequests(first:1, states:OPEN){{ nodes{{ number isDraft url }} }}
283 }}"
284 )
285}
286
287type QueryIndex = HashMap<(usize, usize), PrTarget>;
290
291fn build_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
295 if targets.is_empty() {
296 return None;
297 }
298 let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
301 for t in targets {
302 by_repo
303 .entry((t.owner.as_str(), t.name.as_str()))
304 .or_default()
305 .push(t);
306 }
307 let mut index = HashMap::new();
308 let mut repos = Vec::new();
309 for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
310 let mut frags = Vec::new();
311 for (bi, target) in branches.iter().enumerate() {
312 frags.push(branch_fragment(&format!("b{bi}"), &target.branch));
313 index.insert((ri, bi), (*target).clone());
314 }
315 let owner = Value::String((*owner).to_string());
316 let name = Value::String((*name).to_string());
317 repos.push(format!(
318 "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
319 frags.join("\n")
320 ));
321 }
322 Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
323}
324
325fn badge_from_ref(node: &Value) -> Option<PrBadge> {
329 let pr = node
330 .get("associatedPullRequests")?
331 .get("nodes")?
332 .as_array()?
333 .first()?;
334 let contexts = node
335 .get("target")
336 .and_then(|t| t.get("statusCheckRollup"))
337 .and_then(|r| r.get("contexts"))
338 .and_then(|c| c.get("nodes"))
339 .and_then(Value::as_array)
340 .cloned()
341 .unwrap_or_default();
342 Some(PrBadge {
343 number: pr.get("number").and_then(Value::as_u64)?,
344 is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
345 checks: rollup_check_state(&contexts),
346 url: pr
347 .get("url")
348 .and_then(Value::as_str)
349 .unwrap_or_default()
350 .to_string(),
351 head_oid: node
355 .get("target")
356 .and_then(|t| t.get("oid"))
357 .and_then(Value::as_str)
358 .unwrap_or_default()
359 .to_string(),
360 })
361}
362
363fn parse_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, PrBadge> {
369 let mut out = HashMap::new();
370 let Some(data) = body.get("data") else {
371 return out;
372 };
373 for ((ri, bi), target) in index {
374 let node = data
375 .get(format!("r{ri}"))
376 .and_then(|r| r.get(format!("b{bi}")));
377 let Some(node) = node.filter(|n| !n.is_null()) else {
379 continue;
380 };
381 if let Some(badge) = badge_from_ref(node) {
382 out.insert(target.clone(), badge);
383 }
384 }
385 out
386}
387
388#[must_use]
394pub fn resolve_gh_binary() -> PathBuf {
395 resolve_gh_binary_from(std::env::var_os(GH_BIN_ENV), GH_BINARY_CANDIDATES)
396}
397
398fn resolve_gh_binary_from(
401 env_override: Option<std::ffi::OsString>,
402 candidates: &[&str],
403) -> PathBuf {
404 if let Some(path) = env_override.filter(|p| !p.is_empty()) {
405 return PathBuf::from(path);
406 }
407 for candidate in candidates {
408 let path = Path::new(candidate);
409 if path.exists() {
410 return path.to_path_buf();
411 }
412 }
413 PathBuf::from("gh")
414}
415
416fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
419 let output = Command::new(bin)
420 .args(["api", "graphql", "-f"])
421 .arg(format!("query={query}"))
422 .output()
423 .with_context(|| {
424 format!(
425 "failed to run {} (is the GitHub CLI installed?)",
426 bin.display()
427 )
428 })?;
429 if !output.status.success() {
430 let stderr = String::from_utf8_lossy(&output.stderr);
431 bail!("gh api graphql failed: {}", stderr.trim());
432 }
433 serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
434}
435
436pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrBadge>> {
448 let Some((query, index)) = build_query(targets) else {
449 return Ok(HashMap::new());
450 };
451 let body = run_gh_graphql(bin, &query)?;
452 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
455 if !errors.is_empty() {
456 bail!("gh api graphql returned errors: {errors:?}");
457 }
458 }
459 Ok(parse_response(&body, &index))
460}
461
462#[derive(Debug, Default)]
468pub struct PrStatusCache {
469 badges: Mutex<HashMap<PrTarget, PrBadge>>,
470}
471
472impl PrStatusCache {
473 #[must_use]
476 pub fn new() -> Self {
477 Self::default()
478 }
479
480 #[must_use]
482 pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrBadge> {
483 let key = PrTarget {
484 owner: owner.to_string(),
485 name: name.to_string(),
486 branch: branch.to_string(),
487 };
488 self.lock().get(&key).cloned()
489 }
490
491 pub fn replace(&self, next: HashMap<PrTarget, PrBadge>) -> bool {
498 let mut guard = self.lock();
499 if *guard == next {
500 return false;
501 }
502 *guard = next;
503 true
504 }
505
506 #[must_use]
508 pub fn any_pending(&self) -> bool {
509 self.lock()
510 .values()
511 .any(|b| b.checks == PrCheckState::Pending)
512 }
513
514 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrBadge>> {
517 self.badges.lock().unwrap_or_else(PoisonError::into_inner)
518 }
519}
520
521#[cfg(test)]
522#[allow(clippy::unwrap_used, clippy::expect_used)]
523mod tests {
524 use super::*;
525 use crate::test_support::shim::{shim_lock, write_exec_script};
526 use serde_json::json;
527 use std::sync::MutexGuard;
528
529 fn target(branch: &str) -> PrTarget {
530 PrTarget {
531 owner: "rust-works".into(),
532 name: "omni-dev".into(),
533 branch: branch.into(),
534 }
535 }
536
537 #[test]
541 fn rollup_is_none_for_an_empty_rollup() {
542 assert_eq!(rollup_check_state(&[]), PrCheckState::None);
543 }
544
545 #[test]
546 fn rollup_reads_completed_check_run_conclusions() {
547 for (conclusion, want) in [
548 ("SUCCESS", PrCheckState::Success),
549 ("NEUTRAL", PrCheckState::Success),
550 ("SKIPPED", PrCheckState::Success),
551 ("FAILURE", PrCheckState::Failure),
552 ("CANCELLED", PrCheckState::Failure),
553 ("TIMED_OUT", PrCheckState::Failure),
554 ("ACTION_REQUIRED", PrCheckState::Failure),
555 ("STARTUP_FAILURE", PrCheckState::Failure),
556 ("STALE", PrCheckState::Failure),
557 ] {
558 let entry =
559 json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
560 assert_eq!(
561 rollup_check_state(&[entry]),
562 want,
563 "conclusion {conclusion} should be {want:?}"
564 );
565 }
566 }
567
568 #[test]
569 fn rollup_treats_an_incomplete_check_run_as_pending() {
570 for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
572 let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
573 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
574 }
575 }
576
577 #[test]
578 fn rollup_reads_status_context_states() {
579 for (state, want) in [
581 ("SUCCESS", PrCheckState::Success),
582 ("FAILURE", PrCheckState::Failure),
583 ("ERROR", PrCheckState::Failure),
584 ("PENDING", PrCheckState::Pending),
585 ("EXPECTED", PrCheckState::Pending),
586 ] {
587 let entry = json!({"__typename":"StatusContext","state":state});
588 assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
589 }
590 }
591
592 #[test]
593 fn rollup_never_reads_an_unknown_value_as_a_pass() {
594 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
596 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
597 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
599 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
600 }
601
602 #[test]
603 fn rollup_precedence_is_failure_then_pending_then_success() {
604 let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
605 let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
606 let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
607 assert_eq!(
609 rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
610 PrCheckState::Failure
611 );
612 assert_eq!(
613 rollup_check_state(&[bad, ok.clone()]),
614 PrCheckState::Failure
615 );
616 assert_eq!(
618 rollup_check_state(&[ok.clone(), run]),
619 PrCheckState::Pending
620 );
621 assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
622 }
623
624 fn run(conclusion: &str, suite: Option<&str>) -> Value {
627 let mut e = json!({
628 "__typename": "CheckRun",
629 "status": "COMPLETED",
630 "conclusion": conclusion,
631 });
632 if let Some(s) = suite {
633 e["checkSuite"] = json!({ "status": s });
634 }
635 e
636 }
637
638 #[test]
639 fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
640 let contexts = vec![
645 run("SUCCESS", Some("IN_PROGRESS")),
646 run("SUCCESS", Some("IN_PROGRESS")),
647 ];
648 assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
649 assert_eq!(
651 rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
652 PrCheckState::Pending
653 );
654 }
655
656 #[test]
657 fn rollup_is_success_once_every_backing_suite_is_terminal() {
658 let contexts = vec![
659 run("SUCCESS", Some("COMPLETED")),
660 run("SKIPPED", Some("COMPLETED")),
661 ];
662 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
663 }
664
665 #[test]
666 fn rollup_ignores_a_zero_run_zombie_suite() {
667 let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
674 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
675 }
676
677 #[test]
678 fn rollup_tolerates_entries_without_suite_information() {
679 assert_eq!(
683 rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
684 PrCheckState::Success
685 );
686 assert_eq!(
687 rollup_check_state(&[run("SUCCESS", None)]),
688 PrCheckState::Success
689 );
690 assert_eq!(
692 rollup_check_state(&[run("SUCCESS", Some(""))]),
693 PrCheckState::Success
694 );
695 }
696
697 #[test]
698 fn rollup_failure_still_dominates_a_running_suite() {
699 let contexts = vec![
701 run("FAILURE", Some("IN_PROGRESS")),
702 run("SUCCESS", Some("IN_PROGRESS")),
703 ];
704 assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
705 }
706
707 #[test]
708 fn build_query_asks_for_the_backing_suite_status() {
709 let (query, _) = build_query(&[target("main")]).unwrap();
710 assert!(query.contains("checkSuite{ status }"), "{query}");
711 }
712
713 #[test]
716 fn build_query_is_none_without_targets() {
717 assert!(build_query(&[]).is_none());
718 }
719
720 #[test]
721 fn build_query_groups_branches_under_one_repo_alias() {
722 let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
723 assert_eq!(query.matches("repository(owner:").count(), 1);
725 assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
726 assert!(query.contains(r#""refs/heads/main""#), "{query}");
727 assert!(query.contains(r#""refs/heads/feature""#), "{query}");
728 assert_eq!(index.len(), 2);
729 }
730
731 #[test]
732 fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
733 let (query, _) = build_query(&[target("main")]).unwrap();
738 assert!(
739 query.contains("associatedPullRequests(first:1, states:OPEN)"),
740 "{query}"
741 );
742 let commit_block = query.find("...on Commit").unwrap();
744 let pr_lookup = query.find("associatedPullRequests").unwrap();
745 assert!(
746 pr_lookup > commit_block,
747 "PR lookup must be on the Ref, after the Commit block"
748 );
749 }
750
751 #[test]
752 fn build_query_separates_distinct_repos() {
753 let a = PrTarget {
754 owner: "o1".into(),
755 name: "r1".into(),
756 branch: "main".into(),
757 };
758 let b = PrTarget {
759 owner: "o2".into(),
760 name: "r2".into(),
761 branch: "main".into(),
762 };
763 let (query, index) = build_query(&[a, b]).unwrap();
764 assert_eq!(query.matches("repository(owner:").count(), 2);
765 assert_eq!(index.len(), 2);
766 }
767
768 #[test]
769 fn build_query_escapes_branch_names() {
770 let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
773 assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
774 }
775
776 #[test]
779 fn parse_response_reads_badges_and_skips_absent_refs() {
780 let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
781 let (_, index) = build_query(&targets).unwrap();
782 let body = json!({"data":{"r0":{
784 "b0": {
785 "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
786 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
787 ]}}},
788 "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
789 },
790 "b1": null,
792 "b2": {
794 "target": {"oid":"def","statusCheckRollup":null},
795 "associatedPullRequests":{"nodes":[]}
796 }
797 }}});
798 let out = parse_response(&body, &index);
799 assert_eq!(out.len(), 1, "{out:?}");
800 let badge = out.get(&target("feature")).unwrap();
801 assert_eq!(badge.number, 65);
802 assert!(badge.is_draft);
803 assert_eq!(badge.checks, PrCheckState::Success);
804 assert_eq!(badge.url, "u");
805 }
806
807 #[test]
808 fn parse_response_reads_a_pr_with_no_checks_as_none() {
809 let targets = vec![target("feature")];
810 let (_, index) = build_query(&targets).unwrap();
811 let body = json!({"data":{"r0":{"b0":{
812 "target": {"oid":"abc","statusCheckRollup":null},
813 "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
814 }}}});
815 let out = parse_response(&body, &index);
816 assert_eq!(
817 out.get(&target("feature")).unwrap().checks,
818 PrCheckState::None
819 );
820 }
821
822 #[test]
823 fn parse_response_tolerates_a_missing_data_block() {
824 let (_, index) = build_query(&[target("x")]).unwrap();
825 assert!(parse_response(&json!({}), &index).is_empty());
826 }
827
828 #[test]
831 fn badge_serializes_is_draft_as_camel_case() {
832 let badge = PrBadge {
835 number: 65,
836 is_draft: true,
837 checks: PrCheckState::Pending,
838 url: "u".into(),
839 head_oid: String::new(),
840 };
841 let v = serde_json::to_value(&badge).unwrap();
842 assert_eq!(v["isDraft"], json!(true));
843 assert_eq!(v["checks"], json!("pending"));
844 assert!(v.get("is_draft").is_none(), "{v}");
845 }
846
847 #[test]
848 fn check_state_serializes_lowercase() {
849 for (state, want) in [
850 (PrCheckState::Success, "success"),
851 (PrCheckState::Failure, "failure"),
852 (PrCheckState::Pending, "pending"),
853 (PrCheckState::None, "none"),
854 ] {
855 assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
856 }
857 }
858
859 #[test]
862 fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
863 assert_eq!(
864 resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
865 PathBuf::from("/custom/gh")
866 );
867 let existing = tempfile::NamedTempFile::new().unwrap();
868 let existing_path = existing.path().to_str().unwrap();
869 assert_eq!(
870 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
871 PathBuf::from(existing_path)
872 );
873 assert_eq!(
874 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
875 PathBuf::from("gh")
876 );
877 assert_eq!(
879 resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
880 PathBuf::from("gh")
881 );
882 let _ = resolve_gh_binary();
884 }
885
886 fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
903 let guard = shim_lock();
904 let path = dir.join("fake-gh");
905 write_exec_script(
906 &path,
907 &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
908 );
909 (path, guard)
910 }
911
912 #[test]
913 fn resolve_with_asks_nothing_for_no_targets() {
914 let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
917 assert!(out.is_empty());
918 }
919
920 #[test]
921 fn resolve_with_errors_when_gh_is_missing() {
922 let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
925 let msg = format!("{err:#}");
926 assert!(msg.contains("failed to run"), "{msg}");
927 assert!(msg.contains("GitHub CLI"), "{msg}");
928 }
929
930 #[test]
931 fn resolve_with_errors_on_a_nonzero_exit() {
932 let dir = tempfile::tempdir().unwrap();
934 let (bin, _shim) = fake_gh(dir.path(), "", 1);
935 let err = resolve_with(&bin, &[target("main")]).unwrap_err();
936 assert!(
937 format!("{err:#}").contains("gh api graphql failed"),
938 "{err:#}"
939 );
940 }
941
942 #[test]
943 fn resolve_with_errors_on_unparseable_output() {
944 let dir = tempfile::tempdir().unwrap();
945 let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
946 let err = resolve_with(&bin, &[target("main")]).unwrap_err();
947 assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
948 }
949
950 #[test]
951 fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
952 let dir = tempfile::tempdir().unwrap();
956 let (bin, _shim) = fake_gh(
957 dir.path(),
958 r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
959 0,
960 );
961 let err = resolve_with(&bin, &[target("main")]).unwrap_err();
962 let msg = format!("{err:#}");
963 assert!(msg.contains("returned errors"), "{msg}");
964 assert!(msg.contains("rate limit"), "{msg}");
965 }
966
967 #[test]
968 fn resolve_with_ignores_an_empty_errors_array() {
969 let dir = tempfile::tempdir().unwrap();
971 let (bin, _shim) = fake_gh(
972 dir.path(),
973 r#"{"errors":[],"data":{"r0":{"b0":{
974 "target":{"oid":"a","statusCheckRollup":null},
975 "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
976 0,
977 );
978 let out = resolve_with(&bin, &[target("main")]).unwrap();
979 assert_eq!(out.get(&target("main")).unwrap().number, 9);
980 }
981
982 #[test]
983 fn resolve_with_reads_a_real_reply_end_to_end() {
984 let dir = tempfile::tempdir().unwrap();
985 let (bin, _shim) = fake_gh(
986 dir.path(),
987 r#"{"data":{"r0":{"b0":{
988 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
989 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
990 ]}}},
991 "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
992 0,
993 );
994 let out = resolve_with(&bin, &[target("main")]).unwrap();
995 let badge = out.get(&target("main")).unwrap();
996 assert_eq!(badge.number, 42);
997 assert!(badge.is_draft);
998 assert_eq!(badge.checks, PrCheckState::Failure);
999 }
1000
1001 #[test]
1004 fn cache_replace_reports_whether_anything_changed() {
1005 let cache = PrStatusCache::new();
1006 let badge = PrBadge {
1007 number: 1,
1008 is_draft: false,
1009 checks: PrCheckState::Pending,
1010 url: "u".into(),
1011 head_oid: String::new(),
1012 };
1013 let mut map = HashMap::new();
1014 map.insert(target("f"), badge);
1015
1016 assert!(cache.replace(map.clone()));
1018 assert!(!cache.replace(map.clone()));
1021
1022 let mut moved = map.clone();
1024 moved.get_mut(&target("f")).unwrap().checks = PrCheckState::Success;
1025 assert!(cache.replace(moved));
1026 assert!(cache.replace(HashMap::new()));
1028 assert!(!cache.replace(HashMap::new()));
1029 }
1030
1031 #[test]
1032 fn cache_get_and_any_pending() {
1033 let cache = PrStatusCache::new();
1034 assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1035 assert!(!cache.any_pending());
1036
1037 let mut map = HashMap::new();
1038 map.insert(
1039 target("f"),
1040 PrBadge {
1041 number: 1,
1042 is_draft: false,
1043 checks: PrCheckState::Pending,
1044 url: "u".into(),
1045 head_oid: String::new(),
1046 },
1047 );
1048 cache.replace(map.clone());
1049 assert_eq!(cache.get("rust-works", "omni-dev", "f").unwrap().number, 1);
1050 assert!(cache.any_pending());
1051 assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1053
1054 map.get_mut(&target("f")).unwrap().checks = PrCheckState::Success;
1055 cache.replace(map);
1056 assert!(!cache.any_pending());
1057 }
1058}