1use std::collections::{BTreeMap, HashMap, HashSet};
55use std::path::{Path, PathBuf};
56use std::sync::{Mutex, PoisonError};
57
58use anyhow::{bail, Context, Result};
59use serde::{Deserialize, Serialize};
60use serde_json::Value;
61
62const GH_BIN_ENV: &str = "OMNI_DEV_GH_BIN";
67
68const GH_BINARY_CANDIDATES: &[&str] = &[
71 "/opt/homebrew/bin/gh",
72 "/usr/local/bin/gh",
73 "/home/linuxbrew/.linuxbrew/bin/gh",
74 "/usr/bin/gh",
75];
76
77const FAILURE_STATES: &[&str] = &[
80 "FAILURE",
81 "ERROR",
82 "CANCELLED",
83 "TIMED_OUT",
84 "ACTION_REQUIRED",
85 "STARTUP_FAILURE",
86 "STALE",
87];
88
89const SUCCESS_STATES: &[&str] = &["SUCCESS", "NEUTRAL", "SKIPPED"];
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "lowercase")]
96pub enum PrCheckState {
97 Success,
99 Failure,
101 Pending,
104 None,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct PrBadge {
117 pub number: u64,
119 #[serde(rename = "isDraft")]
121 pub is_draft: bool,
122 pub checks: PrCheckState,
124 pub url: String,
126 #[serde(skip)]
134 pub head_oid: String,
135}
136
137impl PrBadge {
138 #[must_use]
151 pub fn is_stale_for(&self, head_sha: Option<&str>) -> bool {
152 head_sha.is_some_and(|sha| sha != self.head_oid)
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
163pub struct PrTarget {
164 pub owner: String,
166 pub name: String,
168 pub branch: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum PrResolution {
182 Pr(PrBadge),
184 NoPr,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199enum EntryState {
200 Failure,
201 Pending,
202 Success,
203}
204
205fn check_entry_state(entry: &Value) -> EntryState {
212 let status = entry.get("status").and_then(Value::as_str).unwrap_or("");
213 if !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED") {
214 return EntryState::Pending;
215 }
216 let raw = entry
220 .get("conclusion")
221 .and_then(Value::as_str)
222 .filter(|s| !s.is_empty())
223 .or_else(|| {
224 entry
225 .get("state")
226 .and_then(Value::as_str)
227 .filter(|s| !s.is_empty())
228 })
229 .unwrap_or("")
230 .to_ascii_uppercase();
231 if FAILURE_STATES.contains(&raw.as_str()) {
232 return EntryState::Failure;
233 }
234 if SUCCESS_STATES.contains(&raw.as_str()) {
235 return EntryState::Success;
236 }
237 EntryState::Pending
238}
239
240fn rollup_check_state(contexts: &[Value]) -> PrCheckState {
245 if contexts.is_empty() {
246 return PrCheckState::None;
247 }
248 let mut saw_pending = false;
249 for entry in contexts {
250 match check_entry_state(entry) {
251 EntryState::Failure => return PrCheckState::Failure,
252 EntryState::Pending => saw_pending = true,
253 EntryState::Success => {}
254 }
255 }
256 if saw_pending {
257 return PrCheckState::Pending;
258 }
259 if contexts.iter().any(suite_still_running) {
262 return PrCheckState::Pending;
263 }
264 PrCheckState::Success
268}
269
270fn suite_still_running(entry: &Value) -> bool {
286 entry
287 .get("checkSuite")
288 .and_then(|suite| suite.get("status"))
289 .and_then(Value::as_str)
290 .is_some_and(|status| !status.is_empty() && !status.eq_ignore_ascii_case("COMPLETED"))
291}
292
293const ROLLUP_FRAGMENT: &str = "statusCheckRollup{ contexts(first:100){ nodes{
299 __typename
300 ...on CheckRun{ status conclusion checkSuite{ status } }
301 ...on StatusContext{ state }
302 } } }";
303
304fn branch_fragment(alias: &str, branch: &str) -> String {
314 let qualified = Value::String(format!("refs/heads/{branch}"));
317 format!(
318 r"{alias}: ref(qualifiedName:{qualified}){{
319 target{{ ...on Commit{{ oid
320 {ROLLUP_FRAGMENT}
321 }} }}
322 associatedPullRequests(first:1, states:OPEN){{ nodes{{ number isDraft url }} }}
323 }}"
324 )
325}
326
327fn merge_branch_fragment(alias: &str, branch: &str) -> String {
338 let qualified = Value::String(format!("refs/heads/{branch}"));
339 format!(
340 r"{alias}: ref(qualifiedName:{qualified}){{
341 target{{ ...on Commit{{ oid
342 {ROLLUP_FRAGMENT}
343 }} }}
344 associatedPullRequests(first:1, states:OPEN){{ nodes{{
345 id number isDraft url headRefOid mergeStateStatus
346 mergeQueueEntry{{ state }}
347 }} }}
348 }}"
349 )
350}
351
352type QueryIndex = HashMap<(usize, usize), PrTarget>;
355
356fn build_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
360 if targets.is_empty() {
361 return None;
362 }
363 let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
366 for t in targets {
367 by_repo
368 .entry((t.owner.as_str(), t.name.as_str()))
369 .or_default()
370 .push(t);
371 }
372 let mut index = HashMap::new();
373 let mut repos = Vec::new();
374 for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
375 let mut frags = Vec::new();
376 for (bi, target) in branches.iter().enumerate() {
377 frags.push(branch_fragment(&format!("b{bi}"), &target.branch));
378 index.insert((ri, bi), (*target).clone());
379 }
380 let owner = Value::String((*owner).to_string());
381 let name = Value::String((*name).to_string());
382 repos.push(format!(
383 "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
384 frags.join("\n")
385 ));
386 }
387 Some((
393 format!(
394 "query{{\nrateLimit{{ limit cost remaining used resetAt }}\n{}\n}}",
395 repos.join("\n")
396 ),
397 index,
398 ))
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub struct QueryRateLimit {
409 pub cost: u64,
411 pub used: u64,
413 pub limit: u64,
415 pub remaining: u64,
417 pub reset: i64,
420}
421
422fn parse_rate_limit(body: &Value) -> Option<QueryRateLimit> {
427 let rl = body.get("data")?.get("rateLimit")?;
428 let reset = rl
429 .get("resetAt")
430 .and_then(Value::as_str)
431 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
432 .map_or(0, |dt| dt.timestamp());
433 Some(QueryRateLimit {
434 cost: rl.get("cost")?.as_u64()?,
435 used: rl.get("used")?.as_u64()?,
436 limit: rl.get("limit")?.as_u64()?,
437 remaining: rl.get("remaining")?.as_u64()?,
438 reset,
439 })
440}
441
442fn badge_from_ref(node: &Value) -> Option<PrBadge> {
446 let pr = node
447 .get("associatedPullRequests")?
448 .get("nodes")?
449 .as_array()?
450 .first()?;
451 let contexts = node
452 .get("target")
453 .and_then(|t| t.get("statusCheckRollup"))
454 .and_then(|r| r.get("contexts"))
455 .and_then(|c| c.get("nodes"))
456 .and_then(Value::as_array)
457 .cloned()
458 .unwrap_or_default();
459 Some(PrBadge {
460 number: pr.get("number").and_then(Value::as_u64)?,
461 is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
462 checks: rollup_check_state(&contexts),
463 url: pr
464 .get("url")
465 .and_then(Value::as_str)
466 .unwrap_or_default()
467 .to_string(),
468 head_oid: node
472 .get("target")
473 .and_then(|t| t.get("oid"))
474 .and_then(Value::as_str)
475 .unwrap_or_default()
476 .to_string(),
477 })
478}
479
480fn resolution_from_ref(node: &Value) -> Option<PrResolution> {
486 let prs = node
487 .get("associatedPullRequests")?
488 .get("nodes")?
489 .as_array()?;
490 if prs.is_empty() {
491 return Some(PrResolution::NoPr);
492 }
493 badge_from_ref(node).map(PrResolution::Pr)
494}
495
496fn parse_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, PrResolution> {
504 let mut out = HashMap::new();
505 let Some(data) = body.get("data") else {
506 return out;
507 };
508 for ((ri, bi), target) in index {
509 let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
512 continue;
513 };
514 let Some(node) = repo.get(format!("b{bi}")) else {
515 continue;
516 };
517 if node.is_null() {
520 out.insert(target.clone(), PrResolution::NoPr);
521 continue;
522 }
523 if let Some(resolution) = resolution_from_ref(node) {
524 out.insert(target.clone(), resolution);
525 }
526 }
527 out
528}
529
530#[must_use]
536pub fn resolve_gh_binary() -> PathBuf {
537 resolve_gh_binary_from(std::env::var_os(GH_BIN_ENV), GH_BINARY_CANDIDATES)
538}
539
540fn resolve_gh_binary_from(
543 env_override: Option<std::ffi::OsString>,
544 candidates: &[&str],
545) -> PathBuf {
546 if let Some(path) = env_override.filter(|p| !p.is_empty()) {
547 return PathBuf::from(path);
548 }
549 for candidate in candidates {
550 let path = Path::new(candidate);
551 if path.exists() {
552 return path.to_path_buf();
553 }
554 }
555 PathBuf::from("gh")
556}
557
558fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
561 let query_arg = format!("query={query}");
562 let output = crate::github_metrics::run_gh(
563 bin,
564 ["api", "graphql", "-f", query_arg.as_str()],
565 "api graphql",
566 None,
567 )
568 .with_context(|| {
569 format!(
570 "failed to run {} (is the GitHub CLI installed?)",
571 bin.display()
572 )
573 })?;
574 if !output.status.success() {
575 let stderr = String::from_utf8_lossy(&output.stderr);
576 bail!("gh api graphql failed: {}", stderr.trim());
577 }
578 serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
579}
580
581pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrResolution>> {
597 resolve_inner(bin, targets).map(|(resolutions, _budget)| resolutions)
598}
599
600pub fn resolve_with_budget(
608 bin: &Path,
609 targets: &[PrTarget],
610) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
611 resolve_inner(bin, targets)
612}
613
614fn resolve_inner(
617 bin: &Path,
618 targets: &[PrTarget],
619) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
620 let Some((query, index)) = build_query(targets) else {
621 return Ok((HashMap::new(), None));
622 };
623 let body = run_gh_graphql(bin, &query)?;
624 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
627 if !errors.is_empty() {
628 bail!("gh api graphql returned errors: {errors:?}");
629 }
630 }
631 Ok((parse_response(&body, &index), parse_rate_limit(&body)))
632}
633
634#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct MergeInfo {
642 pub pr_id: String,
644 pub number: u64,
646 pub url: String,
648 pub is_draft: bool,
650 pub head_oid: String,
652 pub merge_state: Option<String>,
655 pub already_queued: bool,
658 pub checks: PrCheckState,
660}
661
662fn build_merge_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
667 if targets.is_empty() {
668 return None;
669 }
670 let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
671 for t in targets {
672 by_repo
673 .entry((t.owner.as_str(), t.name.as_str()))
674 .or_default()
675 .push(t);
676 }
677 let mut index = HashMap::new();
678 let mut repos = Vec::new();
679 for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
680 let mut frags = Vec::new();
681 for (bi, target) in branches.iter().enumerate() {
682 frags.push(merge_branch_fragment(&format!("b{bi}"), &target.branch));
683 index.insert((ri, bi), (*target).clone());
684 }
685 let owner = Value::String((*owner).to_string());
686 let name = Value::String((*name).to_string());
687 repos.push(format!(
688 "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
689 frags.join("\n")
690 ));
691 }
692 Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
693}
694
695fn merge_info_from_ref(node: &Value) -> Option<MergeInfo> {
699 let pr = node
700 .get("associatedPullRequests")?
701 .get("nodes")?
702 .as_array()?
703 .first()?;
704 let contexts = node
705 .get("target")
706 .and_then(|t| t.get("statusCheckRollup"))
707 .and_then(|r| r.get("contexts"))
708 .and_then(|c| c.get("nodes"))
709 .and_then(Value::as_array)
710 .cloned()
711 .unwrap_or_default();
712 Some(MergeInfo {
713 pr_id: pr.get("id").and_then(Value::as_str)?.to_string(),
714 number: pr.get("number").and_then(Value::as_u64)?,
715 url: pr
716 .get("url")
717 .and_then(Value::as_str)
718 .unwrap_or_default()
719 .to_string(),
720 is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
721 head_oid: pr
722 .get("headRefOid")
723 .and_then(Value::as_str)
724 .unwrap_or_default()
725 .to_string(),
726 merge_state: pr
727 .get("mergeStateStatus")
728 .and_then(Value::as_str)
729 .map(str::to_ascii_uppercase),
730 already_queued: pr
731 .get("mergeQueueEntry")
732 .is_some_and(|entry| !entry.is_null()),
733 checks: rollup_check_state(&contexts),
734 })
735}
736
737fn parse_merge_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, MergeInfo> {
741 let mut out = HashMap::new();
742 let Some(data) = body.get("data") else {
743 return out;
744 };
745 for ((ri, bi), target) in index {
746 let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
747 continue;
748 };
749 let Some(node) = repo.get(format!("b{bi}")).filter(|n| !n.is_null()) else {
750 continue;
751 };
752 if let Some(info) = merge_info_from_ref(node) {
753 out.insert(target.clone(), info);
754 }
755 }
756 out
757}
758
759pub fn resolve_merge_targets(
767 bin: &Path,
768 targets: &[PrTarget],
769) -> Result<HashMap<PrTarget, MergeInfo>> {
770 let Some((query, index)) = build_merge_query(targets) else {
771 return Ok(HashMap::new());
772 };
773 let body = run_gh_graphql(bin, &query)?;
774 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
775 if !errors.is_empty() {
776 bail!("gh api graphql returned errors: {errors:?}");
777 }
778 }
779 Ok(parse_merge_response(&body, &index))
780}
781
782#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum EnqueueOutcome {
785 Queued(Option<String>),
788 Rejected(String),
793}
794
795fn enqueue_error_message(errors: &[Value]) -> String {
797 let joined = errors
798 .iter()
799 .filter_map(|e| e.get("message").and_then(Value::as_str))
800 .collect::<Vec<_>>()
801 .join("; ");
802 if joined.is_empty() {
803 "enqueue rejected by GitHub".to_string()
804 } else {
805 joined
806 }
807}
808
809pub fn enqueue_pull_request(bin: &Path, pr_node_id: &str) -> Result<EnqueueOutcome> {
818 let id_lit = Value::String(pr_node_id.to_string());
820 let mutation =
821 format!("mutation{{ enqueuePullRequest(input:{{pullRequestId:{id_lit}}}){{ mergeQueueEntry{{ state }} }} }}");
822 let query_arg = format!("query={mutation}");
823 let output = crate::github_metrics::run_gh(
824 bin,
825 ["api", "graphql", "-f", query_arg.as_str()],
826 "api graphql",
827 None,
828 )
829 .with_context(|| {
830 format!(
831 "failed to run {} (is the GitHub CLI installed?)",
832 bin.display()
833 )
834 })?;
835 if !output.status.success() {
836 let msg = serde_json::from_slice::<Value>(&output.stdout)
839 .ok()
840 .and_then(|body| {
841 body.get("errors")
842 .and_then(Value::as_array)
843 .filter(|errs| !errs.is_empty())
844 .map(|errs| enqueue_error_message(errs))
845 })
846 .unwrap_or_else(|| String::from_utf8_lossy(&output.stderr).trim().to_string());
847 return Ok(EnqueueOutcome::Rejected(msg));
848 }
849 let body: Value =
850 serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")?;
851 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
852 if !errors.is_empty() {
853 return Ok(EnqueueOutcome::Rejected(enqueue_error_message(errors)));
854 }
855 }
856 let state = body
857 .pointer("/data/enqueuePullRequest/mergeQueueEntry/state")
858 .and_then(Value::as_str)
859 .map(str::to_string);
860 Ok(EnqueueOutcome::Queued(state))
861}
862
863#[derive(Debug, Default)]
869pub struct PrStatusCache {
870 resolutions: Mutex<HashMap<PrTarget, PrResolution>>,
871}
872
873impl PrStatusCache {
874 #[must_use]
877 pub fn new() -> Self {
878 Self::default()
879 }
880
881 #[must_use]
884 pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrResolution> {
885 let key = PrTarget {
886 owner: owner.to_string(),
887 name: name.to_string(),
888 branch: branch.to_string(),
889 };
890 self.lock().get(&key).cloned()
891 }
892
893 pub fn replace(&self, next: HashMap<PrTarget, PrResolution>) -> bool {
902 let mut guard = self.lock();
903 if *guard == next {
904 return false;
905 }
906 *guard = next;
907 true
908 }
909
910 pub fn retain_targets(&self, keep: &HashSet<PrTarget>) -> bool {
921 let mut guard = self.lock();
922 let before = guard.len();
923 guard.retain(|target, _| keep.contains(target));
924 guard.len() != before
925 }
926
927 #[must_use]
931 pub fn entries(&self) -> Vec<(PrTarget, PrResolution)> {
932 self.lock()
933 .iter()
934 .map(|(t, r)| (t.clone(), r.clone()))
935 .collect()
936 }
937
938 pub fn seed(&self, entries: impl IntoIterator<Item = (PrTarget, PrResolution)>) {
946 let mut guard = self.lock();
947 for (target, resolution) in entries {
948 guard.insert(target, resolution);
949 }
950 }
951
952 #[must_use]
955 pub fn any_pending(&self) -> bool {
956 self.lock()
957 .values()
958 .any(|r| matches!(r, PrResolution::Pr(b) if b.checks == PrCheckState::Pending))
959 }
960
961 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrResolution>> {
964 self.resolutions
965 .lock()
966 .unwrap_or_else(PoisonError::into_inner)
967 }
968}
969
970#[cfg(test)]
971#[allow(clippy::unwrap_used, clippy::expect_used)]
972mod tests {
973 use super::*;
974 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
975 use serde_json::json;
976 use std::sync::MutexGuard;
977
978 fn target(branch: &str) -> PrTarget {
979 PrTarget {
980 owner: "rust-works".into(),
981 name: "omni-dev".into(),
982 branch: branch.into(),
983 }
984 }
985
986 #[test]
990 fn rollup_is_none_for_an_empty_rollup() {
991 assert_eq!(rollup_check_state(&[]), PrCheckState::None);
992 }
993
994 #[test]
995 fn rollup_reads_completed_check_run_conclusions() {
996 for (conclusion, want) in [
997 ("SUCCESS", PrCheckState::Success),
998 ("NEUTRAL", PrCheckState::Success),
999 ("SKIPPED", PrCheckState::Success),
1000 ("FAILURE", PrCheckState::Failure),
1001 ("CANCELLED", PrCheckState::Failure),
1002 ("TIMED_OUT", PrCheckState::Failure),
1003 ("ACTION_REQUIRED", PrCheckState::Failure),
1004 ("STARTUP_FAILURE", PrCheckState::Failure),
1005 ("STALE", PrCheckState::Failure),
1006 ] {
1007 let entry =
1008 json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
1009 assert_eq!(
1010 rollup_check_state(&[entry]),
1011 want,
1012 "conclusion {conclusion} should be {want:?}"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn rollup_treats_an_incomplete_check_run_as_pending() {
1019 for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
1021 let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
1022 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1023 }
1024 }
1025
1026 #[test]
1027 fn rollup_reads_status_context_states() {
1028 for (state, want) in [
1030 ("SUCCESS", PrCheckState::Success),
1031 ("FAILURE", PrCheckState::Failure),
1032 ("ERROR", PrCheckState::Failure),
1033 ("PENDING", PrCheckState::Pending),
1034 ("EXPECTED", PrCheckState::Pending),
1035 ] {
1036 let entry = json!({"__typename":"StatusContext","state":state});
1037 assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
1038 }
1039 }
1040
1041 #[test]
1042 fn rollup_never_reads_an_unknown_value_as_a_pass() {
1043 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
1045 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1046 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
1048 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1049 }
1050
1051 #[test]
1052 fn rollup_precedence_is_failure_then_pending_then_success() {
1053 let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
1054 let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
1055 let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
1056 assert_eq!(
1058 rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
1059 PrCheckState::Failure
1060 );
1061 assert_eq!(
1062 rollup_check_state(&[bad, ok.clone()]),
1063 PrCheckState::Failure
1064 );
1065 assert_eq!(
1067 rollup_check_state(&[ok.clone(), run]),
1068 PrCheckState::Pending
1069 );
1070 assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
1071 }
1072
1073 fn run(conclusion: &str, suite: Option<&str>) -> Value {
1076 let mut e = json!({
1077 "__typename": "CheckRun",
1078 "status": "COMPLETED",
1079 "conclusion": conclusion,
1080 });
1081 if let Some(s) = suite {
1082 e["checkSuite"] = json!({ "status": s });
1083 }
1084 e
1085 }
1086
1087 #[test]
1088 fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
1089 let contexts = vec![
1094 run("SUCCESS", Some("IN_PROGRESS")),
1095 run("SUCCESS", Some("IN_PROGRESS")),
1096 ];
1097 assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
1098 assert_eq!(
1100 rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
1101 PrCheckState::Pending
1102 );
1103 }
1104
1105 #[test]
1106 fn rollup_is_success_once_every_backing_suite_is_terminal() {
1107 let contexts = vec![
1108 run("SUCCESS", Some("COMPLETED")),
1109 run("SKIPPED", Some("COMPLETED")),
1110 ];
1111 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1112 }
1113
1114 #[test]
1115 fn rollup_ignores_a_zero_run_zombie_suite() {
1116 let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
1123 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1124 }
1125
1126 #[test]
1127 fn rollup_tolerates_entries_without_suite_information() {
1128 assert_eq!(
1132 rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
1133 PrCheckState::Success
1134 );
1135 assert_eq!(
1136 rollup_check_state(&[run("SUCCESS", None)]),
1137 PrCheckState::Success
1138 );
1139 assert_eq!(
1141 rollup_check_state(&[run("SUCCESS", Some(""))]),
1142 PrCheckState::Success
1143 );
1144 }
1145
1146 #[test]
1147 fn rollup_failure_still_dominates_a_running_suite() {
1148 let contexts = vec![
1150 run("FAILURE", Some("IN_PROGRESS")),
1151 run("SUCCESS", Some("IN_PROGRESS")),
1152 ];
1153 assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
1154 }
1155
1156 #[test]
1157 fn build_query_asks_for_the_backing_suite_status() {
1158 let (query, _) = build_query(&[target("main")]).unwrap();
1159 assert!(query.contains("checkSuite{ status }"), "{query}");
1160 }
1161
1162 #[test]
1163 fn build_query_folds_in_the_rate_limit_block() {
1164 let (query, _) = build_query(&[target("main")]).unwrap();
1166 assert!(
1167 query.contains("rateLimit{ limit cost remaining used resetAt }"),
1168 "{query}"
1169 );
1170 }
1171
1172 #[test]
1173 fn parse_rate_limit_reads_the_folded_budget_block() {
1174 let body = json!({"data":{"rateLimit":
1175 {"limit":5000,"cost":1,"remaining":4970,"used":30,"resetAt":"2026-07-21T16:00:00Z"}}});
1176 let rl = parse_rate_limit(&body).expect("a complete block should parse");
1177 assert_eq!(rl.cost, 1);
1178 assert_eq!(rl.used, 30);
1179 assert_eq!(rl.limit, 5000);
1180 assert_eq!(rl.remaining, 4970);
1181 assert!(rl.reset > 0, "resetAt should parse to an epoch");
1182 assert!(parse_rate_limit(&json!({"data":{"r0":{}}})).is_none());
1184 assert!(parse_rate_limit(
1186 &json!({"data":{"rateLimit":{"limit":5000,"cost":1,"remaining":4970}}})
1187 )
1188 .is_none());
1189 }
1190
1191 #[test]
1194 fn build_query_is_none_without_targets() {
1195 assert!(build_query(&[]).is_none());
1196 }
1197
1198 #[test]
1199 fn build_query_groups_branches_under_one_repo_alias() {
1200 let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
1201 assert_eq!(query.matches("repository(owner:").count(), 1);
1203 assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
1204 assert!(query.contains(r#""refs/heads/main""#), "{query}");
1205 assert!(query.contains(r#""refs/heads/feature""#), "{query}");
1206 assert_eq!(index.len(), 2);
1207 }
1208
1209 #[test]
1210 fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
1211 let (query, _) = build_query(&[target("main")]).unwrap();
1216 assert!(
1217 query.contains("associatedPullRequests(first:1, states:OPEN)"),
1218 "{query}"
1219 );
1220 let commit_block = query.find("...on Commit").unwrap();
1222 let pr_lookup = query.find("associatedPullRequests").unwrap();
1223 assert!(
1224 pr_lookup > commit_block,
1225 "PR lookup must be on the Ref, after the Commit block"
1226 );
1227 }
1228
1229 #[test]
1230 fn build_query_separates_distinct_repos() {
1231 let a = PrTarget {
1232 owner: "o1".into(),
1233 name: "r1".into(),
1234 branch: "main".into(),
1235 };
1236 let b = PrTarget {
1237 owner: "o2".into(),
1238 name: "r2".into(),
1239 branch: "main".into(),
1240 };
1241 let (query, index) = build_query(&[a, b]).unwrap();
1242 assert_eq!(query.matches("repository(owner:").count(), 2);
1243 assert_eq!(index.len(), 2);
1244 }
1245
1246 #[test]
1247 fn build_query_escapes_branch_names() {
1248 let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
1251 assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
1252 }
1253
1254 #[test]
1257 fn parse_response_reads_badges_and_resolves_absent_refs_as_negatives() {
1258 let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
1259 let (_, index) = build_query(&targets).unwrap();
1260 let body = json!({"data":{"r0":{
1262 "b0": {
1263 "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
1264 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1265 ]}}},
1266 "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
1267 },
1268 "b1": null,
1270 "b2": {
1272 "target": {"oid":"def","statusCheckRollup":null},
1273 "associatedPullRequests":{"nodes":[]}
1274 }
1275 }}});
1276 let out = parse_response(&body, &index);
1277 assert_eq!(out.len(), 3, "{out:?}");
1278 let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1279 panic!("expected a badge for feature: {out:?}");
1280 };
1281 assert_eq!(badge.number, 65);
1282 assert!(badge.is_draft);
1283 assert_eq!(badge.checks, PrCheckState::Success);
1284 assert_eq!(badge.url, "u");
1285 assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1286 assert_eq!(out.get(&target("no-pr")), Some(&PrResolution::NoPr));
1287 }
1288
1289 #[test]
1290 fn parse_response_reports_a_pushed_branch_with_no_pr_as_a_negative() {
1291 let (_, index) = build_query(&[target("quiet")]).unwrap();
1292 let body = json!({"data":{"r0":{"b0":{
1293 "target": {"oid":"abc","statusCheckRollup":null},
1294 "associatedPullRequests":{"nodes":[]}
1295 }}}});
1296 let out = parse_response(&body, &index);
1297 assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1298 }
1299
1300 #[test]
1301 fn parse_response_reports_an_unpushed_branch_as_a_negative() {
1302 let (_, index) = build_query(&[target("local-only")]).unwrap();
1303 let body = json!({"data":{"r0":{"b0":null}}});
1304 let out = parse_response(&body, &index);
1305 assert_eq!(out.get(&target("local-only")), Some(&PrResolution::NoPr));
1306 }
1307
1308 #[test]
1309 fn parse_response_leaves_a_missing_alias_unresolved_rather_than_negative() {
1310 let (_, index) = build_query(&[target("x")]).unwrap();
1315 assert!(parse_response(&json!({"data":{"r0":{}}}), &index).is_empty());
1316 assert!(parse_response(&json!({"data":{"r0":null}}), &index).is_empty());
1317 }
1318
1319 #[test]
1320 fn parse_response_leaves_a_malformed_pr_node_unresolved_rather_than_negative() {
1321 let (_, index) = build_query(&[target("x")]).unwrap();
1322 let body = json!({"data":{"r0":{"b0":{
1325 "target": {"oid":"abc","statusCheckRollup":null},
1326 "associatedPullRequests":{"nodes":[{"isDraft":false,"url":"u"}]}
1327 }}}});
1328 assert!(parse_response(&body, &index).is_empty());
1329 }
1330
1331 #[test]
1332 fn parse_response_reads_a_pr_with_no_checks_as_none() {
1333 let targets = vec![target("feature")];
1334 let (_, index) = build_query(&targets).unwrap();
1335 let body = json!({"data":{"r0":{"b0":{
1336 "target": {"oid":"abc","statusCheckRollup":null},
1337 "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
1338 }}}});
1339 let out = parse_response(&body, &index);
1340 let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1342 panic!("expected a badge: {out:?}");
1343 };
1344 assert_eq!(badge.checks, PrCheckState::None);
1345 }
1346
1347 #[test]
1348 fn parse_response_tolerates_a_missing_data_block() {
1349 let (_, index) = build_query(&[target("x")]).unwrap();
1351 assert!(parse_response(&json!({}), &index).is_empty());
1352 }
1353
1354 #[test]
1357 fn badge_serializes_is_draft_as_camel_case() {
1358 let badge = PrBadge {
1361 number: 65,
1362 is_draft: true,
1363 checks: PrCheckState::Pending,
1364 url: "u".into(),
1365 head_oid: String::new(),
1366 };
1367 let v = serde_json::to_value(&badge).unwrap();
1368 assert_eq!(v["isDraft"], json!(true));
1369 assert_eq!(v["checks"], json!("pending"));
1370 assert!(v.get("is_draft").is_none(), "{v}");
1371 }
1372
1373 #[test]
1374 fn check_state_serializes_lowercase() {
1375 for (state, want) in [
1376 (PrCheckState::Success, "success"),
1377 (PrCheckState::Failure, "failure"),
1378 (PrCheckState::Pending, "pending"),
1379 (PrCheckState::None, "none"),
1380 ] {
1381 assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
1382 }
1383 }
1384
1385 #[test]
1388 fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
1389 assert_eq!(
1390 resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
1391 PathBuf::from("/custom/gh")
1392 );
1393 let existing = tempfile::NamedTempFile::new().unwrap();
1394 let existing_path = existing.path().to_str().unwrap();
1395 assert_eq!(
1396 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
1397 PathBuf::from(existing_path)
1398 );
1399 assert_eq!(
1400 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
1401 PathBuf::from("gh")
1402 );
1403 assert_eq!(
1405 resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
1406 PathBuf::from("gh")
1407 );
1408 let _ = resolve_gh_binary();
1410 }
1411
1412 fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1429 let guard = shim_lock();
1430 let path = dir.join("fake-gh");
1431 write_exec_script(
1432 &path,
1433 &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
1434 );
1435 (path, guard)
1436 }
1437
1438 #[test]
1439 fn resolve_with_asks_nothing_for_no_targets() {
1440 let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1443 assert!(out.is_empty());
1444 }
1445
1446 #[test]
1447 fn resolve_with_errors_when_gh_is_missing() {
1448 let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
1451 let msg = format!("{err:#}");
1452 assert!(msg.contains("failed to run"), "{msg}");
1453 assert!(msg.contains("GitHub CLI"), "{msg}");
1454 }
1455
1456 #[test]
1457 fn resolve_with_errors_on_a_nonzero_exit() {
1458 let dir = tempfile::tempdir().unwrap();
1460 let (bin, _shim) = fake_gh(dir.path(), "", 1);
1461 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1462 assert!(
1463 format!("{err:#}").contains("gh api graphql failed"),
1464 "{err:#}"
1465 );
1466 }
1467
1468 #[test]
1469 fn resolve_with_errors_on_unparseable_output() {
1470 let dir = tempfile::tempdir().unwrap();
1471 let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
1472 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1473 assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
1474 }
1475
1476 #[test]
1477 fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
1478 let dir = tempfile::tempdir().unwrap();
1484 let (bin, _shim) = fake_gh(
1485 dir.path(),
1486 r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
1487 0,
1488 );
1489 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1490 let msg = format!("{err:#}");
1491 assert!(msg.contains("returned errors"), "{msg}");
1492 assert!(msg.contains("rate limit"), "{msg}");
1493 }
1494
1495 #[test]
1496 fn resolve_with_ignores_an_empty_errors_array() {
1497 let dir = tempfile::tempdir().unwrap();
1499 let (bin, _shim) = fake_gh(
1500 dir.path(),
1501 r#"{"errors":[],"data":{"r0":{"b0":{
1502 "target":{"oid":"a","statusCheckRollup":null},
1503 "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
1504 0,
1505 );
1506 let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1507 let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1508 panic!("expected a badge: {out:?}");
1509 };
1510 assert_eq!(badge.number, 9);
1511 }
1512
1513 #[test]
1514 fn resolve_with_reads_a_real_reply_end_to_end() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let (bin, _shim) = fake_gh(
1517 dir.path(),
1518 r#"{"data":{"r0":{"b0":{
1519 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1520 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
1521 ]}}},
1522 "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
1523 0,
1524 );
1525 let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1526 let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1527 panic!("expected a badge: {out:?}");
1528 };
1529 assert_eq!(badge.number, 42);
1530 assert!(badge.is_draft);
1531 assert_eq!(badge.checks, PrCheckState::Failure);
1532 }
1533
1534 #[test]
1535 fn resolve_with_reports_negatives_alongside_badges_end_to_end() {
1536 let dir = tempfile::tempdir().unwrap();
1537 let (bin, _shim) = fake_gh(
1538 dir.path(),
1539 r#"{"data":{"r0":{
1540 "b0":{
1541 "target":{"oid":"a","statusCheckRollup":null},
1542 "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}},
1543 "b1":null,
1544 "b2":{
1545 "target":{"oid":"b","statusCheckRollup":null},
1546 "associatedPullRequests":{"nodes":[]}}}}}"#,
1547 0,
1548 );
1549 let targets = vec![target("main"), target("unpushed"), target("quiet")];
1550 let out = retry_on_etxtbsy(|| resolve_with(&bin, &targets)).unwrap();
1551 assert_eq!(out.len(), 3, "{out:?}");
1552 assert!(
1553 matches!(out.get(&target("main")), Some(PrResolution::Pr(b)) if b.number == 7),
1554 "{out:?}"
1555 );
1556 assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1557 assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1558 }
1559
1560 fn pending_pr(number: u64) -> PrResolution {
1564 PrResolution::Pr(PrBadge {
1565 number,
1566 is_draft: false,
1567 checks: PrCheckState::Pending,
1568 url: "u".into(),
1569 head_oid: String::new(),
1570 })
1571 }
1572
1573 #[test]
1574 fn cache_replace_reports_whether_anything_changed() {
1575 let cache = PrStatusCache::new();
1576 let mut map = HashMap::new();
1577 map.insert(target("f"), pending_pr(1));
1578
1579 assert!(cache.replace(map.clone()));
1581 assert!(!cache.replace(map.clone()));
1584
1585 let mut moved = map.clone();
1587 if let Some(PrResolution::Pr(badge)) = moved.get_mut(&target("f")) {
1588 badge.checks = PrCheckState::Success;
1589 }
1590 assert!(cache.replace(moved));
1591 assert!(cache.replace(HashMap::new()));
1593 assert!(!cache.replace(HashMap::new()));
1594 }
1595
1596 #[test]
1597 fn cache_replace_counts_a_new_negative_as_a_change() {
1598 let cache = PrStatusCache::new();
1599 let mut negatives = HashMap::new();
1600 negatives.insert(target("f"), PrResolution::NoPr);
1601
1602 assert!(cache.replace(negatives.clone()));
1605 assert!(!cache.replace(negatives.clone()));
1607
1608 let mut opened = HashMap::new();
1611 opened.insert(target("f"), pending_pr(1));
1612 assert!(cache.replace(opened));
1613 assert!(cache.replace(negatives));
1614 }
1615
1616 #[test]
1617 fn cache_any_pending_ignores_negative_resolutions() {
1618 let cache = PrStatusCache::new();
1620 let mut map = HashMap::new();
1621 map.insert(target("f"), PrResolution::NoPr);
1622 cache.replace(map);
1623 assert!(!cache.any_pending());
1624 }
1625
1626 #[test]
1627 fn cache_get_and_any_pending() {
1628 let cache = PrStatusCache::new();
1629 assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1630 assert!(!cache.any_pending());
1631
1632 let mut map = HashMap::new();
1633 map.insert(target("f"), pending_pr(1));
1634 cache.replace(map.clone());
1635 assert!(
1636 matches!(
1637 cache.get("rust-works", "omni-dev", "f"),
1638 Some(PrResolution::Pr(b)) if b.number == 1
1639 ),
1640 "expected the cached badge back"
1641 );
1642 assert!(cache.any_pending());
1643 assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1645
1646 if let Some(PrResolution::Pr(badge)) = map.get_mut(&target("f")) {
1647 badge.checks = PrCheckState::Success;
1648 }
1649 cache.replace(map);
1650 assert!(!cache.any_pending());
1651 }
1652
1653 #[test]
1654 fn cache_retain_targets_drops_only_the_vanished_entries() {
1655 let cache = PrStatusCache::new();
1658 let mut map = HashMap::new();
1659 map.insert(target("keep"), pending_pr(1));
1660 map.insert(target("drop"), pending_pr(2));
1661 cache.replace(map);
1662
1663 let keep: HashSet<PrTarget> = std::iter::once(target("keep")).collect();
1664 assert!(cache.retain_targets(&keep), "a target was removed");
1665 assert!(cache.get("rust-works", "omni-dev", "keep").is_some());
1666 assert!(cache.get("rust-works", "omni-dev", "drop").is_none());
1667 assert!(!cache.retain_targets(&keep));
1669 }
1670
1671 #[test]
1672 fn cache_seed_then_entries_round_trips_without_a_bump() {
1673 let cache = PrStatusCache::new();
1676 cache.seed([
1677 (target("f"), pending_pr(7)),
1678 (target("g"), PrResolution::NoPr),
1679 ]);
1680 assert!(matches!(
1681 cache.get("rust-works", "omni-dev", "f"),
1682 Some(PrResolution::Pr(b)) if b.number == 7
1683 ));
1684 assert!(matches!(
1685 cache.get("rust-works", "omni-dev", "g"),
1686 Some(PrResolution::NoPr)
1687 ));
1688 let mut entries = cache.entries();
1689 entries.sort_by(|a, b| a.0.cmp(&b.0));
1690 assert_eq!(entries.len(), 2);
1691 assert_eq!(entries[0].0, target("f"));
1692 assert_eq!(entries[1].0, target("g"));
1693 }
1694
1695 #[test]
1698 fn merge_query_asks_for_the_enqueue_fields() {
1699 let (query, index) = build_merge_query(&[target("main")]).unwrap();
1700 for field in [
1702 "id",
1703 "headRefOid",
1704 "mergeStateStatus",
1705 "mergeQueueEntry{ state }",
1706 ] {
1707 assert!(query.contains(field), "missing {field}: {query}");
1708 }
1709 assert!(
1711 query.contains("associatedPullRequests(first:1, states:OPEN)"),
1712 "{query}"
1713 );
1714 assert!(query.contains("checkSuite{ status }"), "{query}");
1715 assert!(!query.contains("rateLimit"), "{query}");
1717 assert_eq!(index.len(), 1);
1718 }
1719
1720 #[test]
1721 fn merge_query_is_none_without_targets() {
1722 assert!(build_merge_query(&[]).is_none());
1723 }
1724
1725 #[test]
1726 fn parse_merge_response_reads_pr_facts_and_already_queued() {
1727 let (_, index) = build_merge_query(&[target("ready"), target("queued")]).unwrap();
1728 let body = json!({"data":{"r0":{
1730 "b0":{
1731 "target":{"oid":"sha-ready","statusCheckRollup":{"contexts":{"nodes":[
1732 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1733 ]}}},
1734 "associatedPullRequests":{"nodes":[{
1735 "id":"PR_ready","number":10,"isDraft":false,
1736 "url":"https://github.com/rust-works/omni-dev/pull/10",
1737 "headRefOid":"sha-ready","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1738 }]}
1739 },
1740 "b1":{
1741 "target":{"oid":"sha-q","statusCheckRollup":null},
1742 "associatedPullRequests":{"nodes":[{
1743 "id":"PR_queued","number":11,"isDraft":true,
1744 "url":"https://github.com/rust-works/omni-dev/pull/11",
1745 "headRefOid":"sha-q","mergeStateStatus":"BLOCKED",
1746 "mergeQueueEntry":{"state":"QUEUED"}
1747 }]}
1748 }
1749 }}});
1750 let out = parse_merge_response(&body, &index);
1751
1752 let ready = out.get(&target("ready")).expect("ready resolved");
1753 assert_eq!(ready.pr_id, "PR_ready");
1754 assert_eq!(ready.number, 10);
1755 assert_eq!(ready.head_oid, "sha-ready");
1756 assert_eq!(ready.merge_state.as_deref(), Some("CLEAN"));
1757 assert!(!ready.is_draft);
1758 assert!(!ready.already_queued);
1759 assert_eq!(ready.checks, PrCheckState::Success);
1760
1761 let queued = out.get(&target("queued")).expect("queued resolved");
1762 assert!(queued.is_draft);
1763 assert!(queued.already_queued);
1764 assert_eq!(queued.merge_state.as_deref(), Some("BLOCKED"));
1765 assert_eq!(queued.checks, PrCheckState::None);
1767 }
1768
1769 #[test]
1770 fn parse_merge_response_treats_absent_or_prless_refs_as_skips() {
1771 let (_, index) = build_merge_query(&[target("null-ref"), target("no-pr")]).unwrap();
1772 let body = json!({"data":{"r0":{
1773 "b0": null,
1775 "b1": {
1777 "target":{"oid":"x","statusCheckRollup":null},
1778 "associatedPullRequests":{"nodes":[]}
1779 }
1780 }}});
1781 let out = parse_merge_response(&body, &index);
1782 assert!(out.is_empty(), "{out:?}");
1785 }
1786
1787 #[test]
1788 fn merge_info_requires_an_id_and_number() {
1789 let node = json!({
1792 "target":{"oid":"x","statusCheckRollup":null},
1793 "associatedPullRequests":{"nodes":[{"number":9,"url":"u"}]}
1794 });
1795 assert!(merge_info_from_ref(&node).is_none());
1796 }
1797
1798 #[test]
1799 fn enqueue_error_message_joins_or_falls_back() {
1800 let errs = vec![
1801 json!({"message":"Merge queue is not enabled"}),
1802 json!({"message":"Pull request is not mergeable"}),
1803 ];
1804 assert_eq!(
1805 enqueue_error_message(&errs),
1806 "Merge queue is not enabled; Pull request is not mergeable"
1807 );
1808 assert_eq!(
1810 enqueue_error_message(&[json!({"code":"X"})]),
1811 "enqueue rejected by GitHub"
1812 );
1813 }
1814
1815 #[test]
1816 fn parse_merge_response_tolerates_a_missing_data_block() {
1817 let (_, index) = build_merge_query(&[target("main")]).unwrap();
1820 assert!(parse_merge_response(&json!({}), &index).is_empty());
1821 }
1822
1823 #[test]
1824 fn resolve_merge_targets_asks_nothing_for_no_targets() {
1825 let out = resolve_merge_targets(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1827 assert!(out.is_empty());
1828 }
1829
1830 #[test]
1831 fn resolve_merge_targets_reads_a_reply_end_to_end() {
1832 let dir = tempfile::tempdir().unwrap();
1833 let (bin, _shim) = fake_gh(
1834 dir.path(),
1835 r#"{"data":{"r0":{"b0":{
1836 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1837 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1838 ]}}},
1839 "associatedPullRequests":{"nodes":[{
1840 "id":"PR_1","number":42,"isDraft":false,"url":"u42",
1841 "headRefOid":"a","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1842 }]}}}}}"#,
1843 0,
1844 );
1845 let out = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap();
1846 let info = out.get(&target("main")).expect("resolved");
1847 assert_eq!(info.pr_id, "PR_1");
1848 assert_eq!(info.number, 42);
1849 assert_eq!(info.head_oid, "a");
1850 assert_eq!(info.merge_state.as_deref(), Some("CLEAN"));
1851 assert!(!info.already_queued);
1852 assert_eq!(info.checks, PrCheckState::Success);
1853 }
1854
1855 #[test]
1856 fn resolve_merge_targets_errors_on_a_graphql_errors_body() {
1857 let dir = tempfile::tempdir().unwrap();
1858 let (bin, _shim) = fake_gh(
1859 dir.path(),
1860 r#"{"errors":[{"message":"Something is wrong"}]}"#,
1861 0,
1862 );
1863 let err = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap_err();
1864 assert!(format!("{err:#}").contains("returned errors"), "{err:#}");
1865 }
1866
1867 #[test]
1868 fn enqueue_pull_request_reports_queued_on_success() {
1869 let dir = tempfile::tempdir().unwrap();
1870 let (bin, _shim) = fake_gh(
1871 dir.path(),
1872 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
1873 0,
1874 );
1875 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1876 assert_eq!(out, EnqueueOutcome::Queued(Some("QUEUED".to_string())));
1877 }
1878
1879 #[test]
1880 fn enqueue_pull_request_maps_a_nonzero_exit_to_rejected() {
1881 let dir = tempfile::tempdir().unwrap();
1884 let (bin, _shim) = fake_gh(
1885 dir.path(),
1886 r#"{"errors":[{"message":"Merge queue is not enabled on this repository"}]}"#,
1887 1,
1888 );
1889 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1890 assert_eq!(
1891 out,
1892 EnqueueOutcome::Rejected("Merge queue is not enabled on this repository".to_string())
1893 );
1894 }
1895
1896 #[test]
1897 fn enqueue_pull_request_maps_a_200_with_errors_to_rejected() {
1898 let dir = tempfile::tempdir().unwrap();
1901 let (bin, _shim) = fake_gh(
1902 dir.path(),
1903 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
1904 0,
1905 );
1906 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1907 assert_eq!(
1908 out,
1909 EnqueueOutcome::Rejected("Pull request is not mergeable".to_string())
1910 );
1911 }
1912}