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
558pub(crate) fn run_gh_graphql(bin: &Path, query: &str) -> Result<Value> {
565 run_gh_graphql_inner(bin, query, false)
566}
567
568pub(crate) fn run_gh_graphql_with_partial_data(bin: &Path, query: &str) -> Result<Value> {
573 run_gh_graphql_inner(bin, query, true)
574}
575
576fn run_gh_graphql_inner(bin: &Path, query: &str, allow_partial_data: bool) -> Result<Value> {
577 let query_arg = format!("query={query}");
578 let output = crate::github_metrics::run_gh(
579 bin,
580 ["api", "graphql", "-f", query_arg.as_str()],
581 "api graphql",
582 None,
583 )
584 .with_context(|| {
585 format!(
586 "failed to run {} (is the GitHub CLI installed?)",
587 bin.display()
588 )
589 })?;
590 if !output.status.success() {
591 if allow_partial_data {
592 if let Ok(body) = serde_json::from_slice::<Value>(&output.stdout) {
593 if body.get("data").and_then(Value::as_object).is_some()
594 && body
595 .get("errors")
596 .and_then(Value::as_array)
597 .is_some_and(|errors| !errors.is_empty())
598 {
599 return Ok(body);
600 }
601 }
602 }
603 let stderr = String::from_utf8_lossy(&output.stderr);
604 bail!("gh api graphql failed: {}", stderr.trim());
605 }
606 serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")
607}
608
609pub fn resolve_with(bin: &Path, targets: &[PrTarget]) -> Result<HashMap<PrTarget, PrResolution>> {
625 resolve_inner(bin, targets).map(|(resolutions, _budget)| resolutions)
626}
627
628pub fn resolve_with_budget(
636 bin: &Path,
637 targets: &[PrTarget],
638) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
639 resolve_inner(bin, targets)
640}
641
642fn resolve_inner(
645 bin: &Path,
646 targets: &[PrTarget],
647) -> Result<(HashMap<PrTarget, PrResolution>, Option<QueryRateLimit>)> {
648 let Some((query, index)) = build_query(targets) else {
649 return Ok((HashMap::new(), None));
650 };
651 let body = run_gh_graphql(bin, &query)?;
652 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
655 if !errors.is_empty() {
656 bail!("gh api graphql returned errors: {errors:?}");
657 }
658 }
659 Ok((parse_response(&body, &index), parse_rate_limit(&body)))
660}
661
662#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct MergeInfo {
670 pub pr_id: String,
672 pub number: u64,
674 pub url: String,
676 pub is_draft: bool,
678 pub head_oid: String,
680 pub merge_state: Option<String>,
683 pub already_queued: bool,
686 pub checks: PrCheckState,
688}
689
690fn build_merge_query(targets: &[PrTarget]) -> Option<(String, QueryIndex)> {
695 if targets.is_empty() {
696 return None;
697 }
698 let mut by_repo: BTreeMap<(&str, &str), Vec<&PrTarget>> = BTreeMap::new();
699 for t in targets {
700 by_repo
701 .entry((t.owner.as_str(), t.name.as_str()))
702 .or_default()
703 .push(t);
704 }
705 let mut index = HashMap::new();
706 let mut repos = Vec::new();
707 for (ri, ((owner, name), branches)) in by_repo.iter().enumerate() {
708 let mut frags = Vec::new();
709 for (bi, target) in branches.iter().enumerate() {
710 frags.push(merge_branch_fragment(&format!("b{bi}"), &target.branch));
711 index.insert((ri, bi), (*target).clone());
712 }
713 let owner = Value::String((*owner).to_string());
714 let name = Value::String((*name).to_string());
715 repos.push(format!(
716 "r{ri}: repository(owner:{owner}, name:{name}){{\n{}\n}}",
717 frags.join("\n")
718 ));
719 }
720 Some((format!("query{{\n{}\n}}", repos.join("\n")), index))
721}
722
723fn merge_info_from_ref(node: &Value) -> Option<MergeInfo> {
727 let pr = node
728 .get("associatedPullRequests")?
729 .get("nodes")?
730 .as_array()?
731 .first()?;
732 let contexts = node
733 .get("target")
734 .and_then(|t| t.get("statusCheckRollup"))
735 .and_then(|r| r.get("contexts"))
736 .and_then(|c| c.get("nodes"))
737 .and_then(Value::as_array)
738 .cloned()
739 .unwrap_or_default();
740 Some(MergeInfo {
741 pr_id: pr.get("id").and_then(Value::as_str)?.to_string(),
742 number: pr.get("number").and_then(Value::as_u64)?,
743 url: pr
744 .get("url")
745 .and_then(Value::as_str)
746 .unwrap_or_default()
747 .to_string(),
748 is_draft: pr.get("isDraft").and_then(Value::as_bool).unwrap_or(false),
749 head_oid: pr
750 .get("headRefOid")
751 .and_then(Value::as_str)
752 .unwrap_or_default()
753 .to_string(),
754 merge_state: pr
755 .get("mergeStateStatus")
756 .and_then(Value::as_str)
757 .map(str::to_ascii_uppercase),
758 already_queued: pr
759 .get("mergeQueueEntry")
760 .is_some_and(|entry| !entry.is_null()),
761 checks: rollup_check_state(&contexts),
762 })
763}
764
765fn parse_merge_response(body: &Value, index: &QueryIndex) -> HashMap<PrTarget, MergeInfo> {
769 let mut out = HashMap::new();
770 let Some(data) = body.get("data") else {
771 return out;
772 };
773 for ((ri, bi), target) in index {
774 let Some(repo) = data.get(format!("r{ri}")).filter(|r| !r.is_null()) else {
775 continue;
776 };
777 let Some(node) = repo.get(format!("b{bi}")).filter(|n| !n.is_null()) else {
778 continue;
779 };
780 if let Some(info) = merge_info_from_ref(node) {
781 out.insert(target.clone(), info);
782 }
783 }
784 out
785}
786
787pub fn resolve_merge_targets(
795 bin: &Path,
796 targets: &[PrTarget],
797) -> Result<HashMap<PrTarget, MergeInfo>> {
798 let Some((query, index)) = build_merge_query(targets) else {
799 return Ok(HashMap::new());
800 };
801 let body = run_gh_graphql(bin, &query)?;
802 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
803 if !errors.is_empty() {
804 bail!("gh api graphql returned errors: {errors:?}");
805 }
806 }
807 Ok(parse_merge_response(&body, &index))
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum EnqueueOutcome {
813 Queued(Option<String>),
816 Rejected(String),
821}
822
823fn enqueue_error_message(errors: &[Value]) -> String {
825 let joined = errors
826 .iter()
827 .filter_map(|e| e.get("message").and_then(Value::as_str))
828 .collect::<Vec<_>>()
829 .join("; ");
830 if joined.is_empty() {
831 "enqueue rejected by GitHub".to_string()
832 } else {
833 joined
834 }
835}
836
837pub fn enqueue_pull_request(bin: &Path, pr_node_id: &str) -> Result<EnqueueOutcome> {
846 let id_lit = Value::String(pr_node_id.to_string());
848 let mutation =
849 format!("mutation{{ enqueuePullRequest(input:{{pullRequestId:{id_lit}}}){{ mergeQueueEntry{{ state }} }} }}");
850 let query_arg = format!("query={mutation}");
851 let output = crate::github_metrics::run_gh(
852 bin,
853 ["api", "graphql", "-f", query_arg.as_str()],
854 "api graphql",
855 None,
856 )
857 .with_context(|| {
858 format!(
859 "failed to run {} (is the GitHub CLI installed?)",
860 bin.display()
861 )
862 })?;
863 if !output.status.success() {
864 let msg = serde_json::from_slice::<Value>(&output.stdout)
867 .ok()
868 .and_then(|body| {
869 body.get("errors")
870 .and_then(Value::as_array)
871 .filter(|errs| !errs.is_empty())
872 .map(|errs| enqueue_error_message(errs))
873 })
874 .unwrap_or_else(|| String::from_utf8_lossy(&output.stderr).trim().to_string());
875 return Ok(EnqueueOutcome::Rejected(msg));
876 }
877 let body: Value =
878 serde_json::from_slice(&output.stdout).context("gh api graphql returned invalid JSON")?;
879 if let Some(errors) = body.get("errors").and_then(Value::as_array) {
880 if !errors.is_empty() {
881 return Ok(EnqueueOutcome::Rejected(enqueue_error_message(errors)));
882 }
883 }
884 let state = body
885 .pointer("/data/enqueuePullRequest/mergeQueueEntry/state")
886 .and_then(Value::as_str)
887 .map(str::to_string);
888 Ok(EnqueueOutcome::Queued(state))
889}
890
891#[derive(Debug, Default)]
897pub struct PrStatusCache {
898 resolutions: Mutex<HashMap<PrTarget, PrResolution>>,
899}
900
901impl PrStatusCache {
902 #[must_use]
905 pub fn new() -> Self {
906 Self::default()
907 }
908
909 #[must_use]
912 pub fn get(&self, owner: &str, name: &str, branch: &str) -> Option<PrResolution> {
913 let key = PrTarget {
914 owner: owner.to_string(),
915 name: name.to_string(),
916 branch: branch.to_string(),
917 };
918 self.lock().get(&key).cloned()
919 }
920
921 pub fn replace(&self, next: HashMap<PrTarget, PrResolution>) -> bool {
930 let mut guard = self.lock();
931 if *guard == next {
932 return false;
933 }
934 *guard = next;
935 true
936 }
937
938 pub fn retain_targets(&self, keep: &HashSet<PrTarget>) -> bool {
949 let mut guard = self.lock();
950 let before = guard.len();
951 guard.retain(|target, _| keep.contains(target));
952 guard.len() != before
953 }
954
955 #[must_use]
959 pub fn entries(&self) -> Vec<(PrTarget, PrResolution)> {
960 self.lock()
961 .iter()
962 .map(|(t, r)| (t.clone(), r.clone()))
963 .collect()
964 }
965
966 pub fn seed(&self, entries: impl IntoIterator<Item = (PrTarget, PrResolution)>) {
974 let mut guard = self.lock();
975 for (target, resolution) in entries {
976 guard.insert(target, resolution);
977 }
978 }
979
980 #[must_use]
983 pub fn any_pending(&self) -> bool {
984 self.lock()
985 .values()
986 .any(|r| matches!(r, PrResolution::Pr(b) if b.checks == PrCheckState::Pending))
987 }
988
989 fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<PrTarget, PrResolution>> {
992 self.resolutions
993 .lock()
994 .unwrap_or_else(PoisonError::into_inner)
995 }
996}
997
998#[cfg(test)]
999#[allow(clippy::unwrap_used, clippy::expect_used)]
1000mod tests {
1001 use super::*;
1002 use crate::test_support::shim::{retry_on_etxtbsy, shim_lock, write_exec_script};
1003 use serde_json::json;
1004 use std::sync::MutexGuard;
1005
1006 fn target(branch: &str) -> PrTarget {
1007 PrTarget {
1008 owner: "rust-works".into(),
1009 name: "omni-dev".into(),
1010 branch: branch.into(),
1011 }
1012 }
1013
1014 #[test]
1018 fn rollup_is_none_for_an_empty_rollup() {
1019 assert_eq!(rollup_check_state(&[]), PrCheckState::None);
1020 }
1021
1022 #[test]
1023 fn rollup_reads_completed_check_run_conclusions() {
1024 for (conclusion, want) in [
1025 ("SUCCESS", PrCheckState::Success),
1026 ("NEUTRAL", PrCheckState::Success),
1027 ("SKIPPED", PrCheckState::Success),
1028 ("FAILURE", PrCheckState::Failure),
1029 ("CANCELLED", PrCheckState::Failure),
1030 ("TIMED_OUT", PrCheckState::Failure),
1031 ("ACTION_REQUIRED", PrCheckState::Failure),
1032 ("STARTUP_FAILURE", PrCheckState::Failure),
1033 ("STALE", PrCheckState::Failure),
1034 ] {
1035 let entry =
1036 json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":conclusion});
1037 assert_eq!(
1038 rollup_check_state(&[entry]),
1039 want,
1040 "conclusion {conclusion} should be {want:?}"
1041 );
1042 }
1043 }
1044
1045 #[test]
1046 fn rollup_treats_an_incomplete_check_run_as_pending() {
1047 for status in ["IN_PROGRESS", "QUEUED", "WAITING", "PENDING"] {
1049 let entry = json!({"__typename":"CheckRun","status":status,"conclusion":null});
1050 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1051 }
1052 }
1053
1054 #[test]
1055 fn rollup_reads_status_context_states() {
1056 for (state, want) in [
1058 ("SUCCESS", PrCheckState::Success),
1059 ("FAILURE", PrCheckState::Failure),
1060 ("ERROR", PrCheckState::Failure),
1061 ("PENDING", PrCheckState::Pending),
1062 ("EXPECTED", PrCheckState::Pending),
1063 ] {
1064 let entry = json!({"__typename":"StatusContext","state":state});
1065 assert_eq!(rollup_check_state(&[entry]), want, "state {state}");
1066 }
1067 }
1068
1069 #[test]
1070 fn rollup_never_reads_an_unknown_value_as_a_pass() {
1071 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"WAT"});
1073 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1074 let entry = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":""});
1076 assert_eq!(rollup_check_state(&[entry]), PrCheckState::Pending);
1077 }
1078
1079 #[test]
1080 fn rollup_precedence_is_failure_then_pending_then_success() {
1081 let ok = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"});
1082 let bad = json!({"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"});
1083 let run = json!({"__typename":"CheckRun","status":"IN_PROGRESS","conclusion":null});
1084 assert_eq!(
1086 rollup_check_state(&[ok.clone(), run.clone(), bad.clone()]),
1087 PrCheckState::Failure
1088 );
1089 assert_eq!(
1090 rollup_check_state(&[bad, ok.clone()]),
1091 PrCheckState::Failure
1092 );
1093 assert_eq!(
1095 rollup_check_state(&[ok.clone(), run]),
1096 PrCheckState::Pending
1097 );
1098 assert_eq!(rollup_check_state(&[ok]), PrCheckState::Success);
1099 }
1100
1101 fn run(conclusion: &str, suite: Option<&str>) -> Value {
1104 let mut e = json!({
1105 "__typename": "CheckRun",
1106 "status": "COMPLETED",
1107 "conclusion": conclusion,
1108 });
1109 if let Some(s) = suite {
1110 e["checkSuite"] = json!({ "status": s });
1111 }
1112 e
1113 }
1114
1115 #[test]
1116 fn rollup_is_pending_while_a_suite_is_still_creating_jobs() {
1117 let contexts = vec![
1122 run("SUCCESS", Some("IN_PROGRESS")),
1123 run("SUCCESS", Some("IN_PROGRESS")),
1124 ];
1125 assert_eq!(rollup_check_state(&contexts), PrCheckState::Pending);
1126 assert_eq!(
1128 rollup_check_state(&[run("SUCCESS", Some("QUEUED"))]),
1129 PrCheckState::Pending
1130 );
1131 }
1132
1133 #[test]
1134 fn rollup_is_success_once_every_backing_suite_is_terminal() {
1135 let contexts = vec![
1136 run("SUCCESS", Some("COMPLETED")),
1137 run("SKIPPED", Some("COMPLETED")),
1138 ];
1139 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1140 }
1141
1142 #[test]
1143 fn rollup_ignores_a_zero_run_zombie_suite() {
1144 let contexts = vec![run("SUCCESS", Some("COMPLETED"))];
1151 assert_eq!(rollup_check_state(&contexts), PrCheckState::Success);
1152 }
1153
1154 #[test]
1155 fn rollup_tolerates_entries_without_suite_information() {
1156 assert_eq!(
1160 rollup_check_state(&[json!({"__typename":"StatusContext","state":"SUCCESS"})]),
1161 PrCheckState::Success
1162 );
1163 assert_eq!(
1164 rollup_check_state(&[run("SUCCESS", None)]),
1165 PrCheckState::Success
1166 );
1167 assert_eq!(
1169 rollup_check_state(&[run("SUCCESS", Some(""))]),
1170 PrCheckState::Success
1171 );
1172 }
1173
1174 #[test]
1175 fn rollup_failure_still_dominates_a_running_suite() {
1176 let contexts = vec![
1178 run("FAILURE", Some("IN_PROGRESS")),
1179 run("SUCCESS", Some("IN_PROGRESS")),
1180 ];
1181 assert_eq!(rollup_check_state(&contexts), PrCheckState::Failure);
1182 }
1183
1184 #[test]
1185 fn build_query_asks_for_the_backing_suite_status() {
1186 let (query, _) = build_query(&[target("main")]).unwrap();
1187 assert!(query.contains("checkSuite{ status }"), "{query}");
1188 }
1189
1190 #[test]
1191 fn build_query_folds_in_the_rate_limit_block() {
1192 let (query, _) = build_query(&[target("main")]).unwrap();
1194 assert!(
1195 query.contains("rateLimit{ limit cost remaining used resetAt }"),
1196 "{query}"
1197 );
1198 }
1199
1200 #[test]
1201 fn parse_rate_limit_reads_the_folded_budget_block() {
1202 let body = json!({"data":{"rateLimit":
1203 {"limit":5000,"cost":1,"remaining":4970,"used":30,"resetAt":"2026-07-21T16:00:00Z"}}});
1204 let rl = parse_rate_limit(&body).expect("a complete block should parse");
1205 assert_eq!(rl.cost, 1);
1206 assert_eq!(rl.used, 30);
1207 assert_eq!(rl.limit, 5000);
1208 assert_eq!(rl.remaining, 4970);
1209 assert!(rl.reset > 0, "resetAt should parse to an epoch");
1210 assert!(parse_rate_limit(&json!({"data":{"r0":{}}})).is_none());
1212 assert!(parse_rate_limit(
1214 &json!({"data":{"rateLimit":{"limit":5000,"cost":1,"remaining":4970}}})
1215 )
1216 .is_none());
1217 }
1218
1219 #[test]
1222 fn build_query_is_none_without_targets() {
1223 assert!(build_query(&[]).is_none());
1224 }
1225
1226 #[test]
1227 fn build_query_groups_branches_under_one_repo_alias() {
1228 let (query, index) = build_query(&[target("main"), target("feature")]).unwrap();
1229 assert_eq!(query.matches("repository(owner:").count(), 1);
1231 assert_eq!(query.matches(": ref(qualifiedName:").count(), 2);
1232 assert!(query.contains(r#""refs/heads/main""#), "{query}");
1233 assert!(query.contains(r#""refs/heads/feature""#), "{query}");
1234 assert_eq!(index.len(), 2);
1235 }
1236
1237 #[test]
1238 fn build_query_reads_open_prs_off_the_ref_not_the_commit() {
1239 let (query, _) = build_query(&[target("main")]).unwrap();
1244 assert!(
1245 query.contains("associatedPullRequests(first:1, states:OPEN)"),
1246 "{query}"
1247 );
1248 let commit_block = query.find("...on Commit").unwrap();
1250 let pr_lookup = query.find("associatedPullRequests").unwrap();
1251 assert!(
1252 pr_lookup > commit_block,
1253 "PR lookup must be on the Ref, after the Commit block"
1254 );
1255 }
1256
1257 #[test]
1258 fn build_query_separates_distinct_repos() {
1259 let a = PrTarget {
1260 owner: "o1".into(),
1261 name: "r1".into(),
1262 branch: "main".into(),
1263 };
1264 let b = PrTarget {
1265 owner: "o2".into(),
1266 name: "r2".into(),
1267 branch: "main".into(),
1268 };
1269 let (query, index) = build_query(&[a, b]).unwrap();
1270 assert_eq!(query.matches("repository(owner:").count(), 2);
1271 assert_eq!(index.len(), 2);
1272 }
1273
1274 #[test]
1275 fn build_query_escapes_branch_names() {
1276 let (query, _) = build_query(&[target(r#"we"ird"#)]).unwrap();
1279 assert!(query.contains(r#"refs/heads/we\"ird"#), "{query}");
1280 }
1281
1282 #[test]
1285 fn parse_response_reads_badges_and_resolves_absent_refs_as_negatives() {
1286 let targets = vec![target("feature"), target("unpushed"), target("no-pr")];
1287 let (_, index) = build_query(&targets).unwrap();
1288 let body = json!({"data":{"r0":{
1290 "b0": {
1291 "target": {"oid":"abc","statusCheckRollup":{"contexts":{"nodes":[
1292 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1293 ]}}},
1294 "associatedPullRequests":{"nodes":[{"number":65,"isDraft":true,"url":"u"}]}
1295 },
1296 "b1": null,
1298 "b2": {
1300 "target": {"oid":"def","statusCheckRollup":null},
1301 "associatedPullRequests":{"nodes":[]}
1302 }
1303 }}});
1304 let out = parse_response(&body, &index);
1305 assert_eq!(out.len(), 3, "{out:?}");
1306 let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1307 panic!("expected a badge for feature: {out:?}");
1308 };
1309 assert_eq!(badge.number, 65);
1310 assert!(badge.is_draft);
1311 assert_eq!(badge.checks, PrCheckState::Success);
1312 assert_eq!(badge.url, "u");
1313 assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1314 assert_eq!(out.get(&target("no-pr")), Some(&PrResolution::NoPr));
1315 }
1316
1317 #[test]
1318 fn parse_response_reports_a_pushed_branch_with_no_pr_as_a_negative() {
1319 let (_, index) = build_query(&[target("quiet")]).unwrap();
1320 let body = json!({"data":{"r0":{"b0":{
1321 "target": {"oid":"abc","statusCheckRollup":null},
1322 "associatedPullRequests":{"nodes":[]}
1323 }}}});
1324 let out = parse_response(&body, &index);
1325 assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1326 }
1327
1328 #[test]
1329 fn parse_response_reports_an_unpushed_branch_as_a_negative() {
1330 let (_, index) = build_query(&[target("local-only")]).unwrap();
1331 let body = json!({"data":{"r0":{"b0":null}}});
1332 let out = parse_response(&body, &index);
1333 assert_eq!(out.get(&target("local-only")), Some(&PrResolution::NoPr));
1334 }
1335
1336 #[test]
1337 fn parse_response_leaves_a_missing_alias_unresolved_rather_than_negative() {
1338 let (_, index) = build_query(&[target("x")]).unwrap();
1343 assert!(parse_response(&json!({"data":{"r0":{}}}), &index).is_empty());
1344 assert!(parse_response(&json!({"data":{"r0":null}}), &index).is_empty());
1345 }
1346
1347 #[test]
1348 fn parse_response_leaves_a_malformed_pr_node_unresolved_rather_than_negative() {
1349 let (_, index) = build_query(&[target("x")]).unwrap();
1350 let body = json!({"data":{"r0":{"b0":{
1353 "target": {"oid":"abc","statusCheckRollup":null},
1354 "associatedPullRequests":{"nodes":[{"isDraft":false,"url":"u"}]}
1355 }}}});
1356 assert!(parse_response(&body, &index).is_empty());
1357 }
1358
1359 #[test]
1360 fn parse_response_reads_a_pr_with_no_checks_as_none() {
1361 let targets = vec![target("feature")];
1362 let (_, index) = build_query(&targets).unwrap();
1363 let body = json!({"data":{"r0":{"b0":{
1364 "target": {"oid":"abc","statusCheckRollup":null},
1365 "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}
1366 }}}});
1367 let out = parse_response(&body, &index);
1368 let Some(PrResolution::Pr(badge)) = out.get(&target("feature")) else {
1370 panic!("expected a badge: {out:?}");
1371 };
1372 assert_eq!(badge.checks, PrCheckState::None);
1373 }
1374
1375 #[test]
1376 fn parse_response_tolerates_a_missing_data_block() {
1377 let (_, index) = build_query(&[target("x")]).unwrap();
1379 assert!(parse_response(&json!({}), &index).is_empty());
1380 }
1381
1382 #[test]
1385 fn badge_serializes_is_draft_as_camel_case() {
1386 let badge = PrBadge {
1389 number: 65,
1390 is_draft: true,
1391 checks: PrCheckState::Pending,
1392 url: "u".into(),
1393 head_oid: String::new(),
1394 };
1395 let v = serde_json::to_value(&badge).unwrap();
1396 assert_eq!(v["isDraft"], json!(true));
1397 assert_eq!(v["checks"], json!("pending"));
1398 assert!(v.get("is_draft").is_none(), "{v}");
1399 }
1400
1401 #[test]
1402 fn check_state_serializes_lowercase() {
1403 for (state, want) in [
1404 (PrCheckState::Success, "success"),
1405 (PrCheckState::Failure, "failure"),
1406 (PrCheckState::Pending, "pending"),
1407 (PrCheckState::None, "none"),
1408 ] {
1409 assert_eq!(serde_json::to_value(state).unwrap(), json!(want));
1410 }
1411 }
1412
1413 #[test]
1416 fn resolve_gh_binary_from_prefers_env_then_candidate_then_fallback() {
1417 assert_eq!(
1418 resolve_gh_binary_from(Some("/custom/gh".into()), &["/usr/bin/gh"]),
1419 PathBuf::from("/custom/gh")
1420 );
1421 let existing = tempfile::NamedTempFile::new().unwrap();
1422 let existing_path = existing.path().to_str().unwrap();
1423 assert_eq!(
1424 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy", existing_path]),
1425 PathBuf::from(existing_path)
1426 );
1427 assert_eq!(
1428 resolve_gh_binary_from(None, &["/no/such/gh/xyzzy"]),
1429 PathBuf::from("gh")
1430 );
1431 assert_eq!(
1433 resolve_gh_binary_from(Some("".into()), &["/no/such/gh/xyzzy"]),
1434 PathBuf::from("gh")
1435 );
1436 let _ = resolve_gh_binary();
1438 }
1439
1440 fn fake_gh(dir: &Path, stdout: &str, code: i32) -> (PathBuf, MutexGuard<'static, ()>) {
1457 let guard = shim_lock();
1458 let path = dir.join("fake-gh");
1459 write_exec_script(
1460 &path,
1461 &format!("#!/bin/sh\ncat <<'JSON'\n{stdout}\nJSON\nexit {code}\n"),
1462 );
1463 (path, guard)
1464 }
1465
1466 #[test]
1467 fn resolve_with_asks_nothing_for_no_targets() {
1468 let out = resolve_with(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1471 assert!(out.is_empty());
1472 }
1473
1474 #[test]
1475 fn resolve_with_errors_when_gh_is_missing() {
1476 let err = resolve_with(Path::new("/no/such/gh/xyzzy"), &[target("main")]).unwrap_err();
1479 let msg = format!("{err:#}");
1480 assert!(msg.contains("failed to run"), "{msg}");
1481 assert!(msg.contains("GitHub CLI"), "{msg}");
1482 }
1483
1484 #[test]
1485 fn resolve_with_errors_on_a_nonzero_exit() {
1486 let dir = tempfile::tempdir().unwrap();
1488 let (bin, _shim) = fake_gh(dir.path(), "", 1);
1489 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1490 assert!(
1491 format!("{err:#}").contains("gh api graphql failed"),
1492 "{err:#}"
1493 );
1494 }
1495
1496 #[test]
1497 fn partial_graphql_runner_keeps_data_from_a_nonzero_exit_without_weakening_the_default() {
1498 let dir = tempfile::tempdir().unwrap();
1499 let (bin, shim) = fake_gh(
1500 dir.path(),
1501 r#"{"data":{"r0":{"i0":null,"i1":{"number":1871}}},"errors":[{"type":"NOT_FOUND","path":["r0","i0"]}]}"#,
1502 1,
1503 );
1504
1505 let partial =
1506 retry_on_etxtbsy(|| run_gh_graphql_with_partial_data(&bin, "query {}")).unwrap();
1507 assert_eq!(partial["data"]["r0"]["i1"]["number"], 1871);
1508 assert_eq!(partial["errors"][0]["type"], "NOT_FOUND");
1509
1510 let err = retry_on_etxtbsy(|| run_gh_graphql(&bin, "query {}")).unwrap_err();
1511 assert!(err.to_string().contains("gh api graphql failed"), "{err}");
1512
1513 drop(shim);
1514 let (bin, shim) = fake_gh(dir.path(), r#"{"data":{"r0":{}},"errors":[]}"#, 1);
1515 let err =
1516 retry_on_etxtbsy(|| run_gh_graphql_with_partial_data(&bin, "query {}")).unwrap_err();
1517 assert!(err.to_string().contains("gh api graphql failed"), "{err}");
1518
1519 drop(shim);
1520 let (bin, _shim) = fake_gh(dir.path(), "not JSON", 1);
1521 let err =
1522 retry_on_etxtbsy(|| run_gh_graphql_with_partial_data(&bin, "query {}")).unwrap_err();
1523 assert!(err.to_string().contains("gh api graphql failed"), "{err}");
1524 }
1525
1526 #[test]
1527 fn resolve_with_errors_on_unparseable_output() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let (bin, _shim) = fake_gh(dir.path(), "not json at all", 0);
1530 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1531 assert!(format!("{err:#}").contains("invalid JSON"), "{err:#}");
1532 }
1533
1534 #[test]
1535 fn resolve_with_surfaces_graphql_errors_rather_than_reporting_no_badges() {
1536 let dir = tempfile::tempdir().unwrap();
1542 let (bin, _shim) = fake_gh(
1543 dir.path(),
1544 r#"{"data":null,"errors":[{"message":"API rate limit exceeded"}]}"#,
1545 0,
1546 );
1547 let err = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap_err();
1548 let msg = format!("{err:#}");
1549 assert!(msg.contains("returned errors"), "{msg}");
1550 assert!(msg.contains("rate limit"), "{msg}");
1551 }
1552
1553 #[test]
1554 fn resolve_with_ignores_an_empty_errors_array() {
1555 let dir = tempfile::tempdir().unwrap();
1557 let (bin, _shim) = fake_gh(
1558 dir.path(),
1559 r#"{"errors":[],"data":{"r0":{"b0":{
1560 "target":{"oid":"a","statusCheckRollup":null},
1561 "associatedPullRequests":{"nodes":[{"number":9,"isDraft":false,"url":"u"}]}}}}}"#,
1562 0,
1563 );
1564 let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1565 let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1566 panic!("expected a badge: {out:?}");
1567 };
1568 assert_eq!(badge.number, 9);
1569 }
1570
1571 #[test]
1572 fn resolve_with_reads_a_real_reply_end_to_end() {
1573 let dir = tempfile::tempdir().unwrap();
1574 let (bin, _shim) = fake_gh(
1575 dir.path(),
1576 r#"{"data":{"r0":{"b0":{
1577 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1578 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}
1579 ]}}},
1580 "associatedPullRequests":{"nodes":[{"number":42,"isDraft":true,"url":"u42"}]}}}}}"#,
1581 0,
1582 );
1583 let out = retry_on_etxtbsy(|| resolve_with(&bin, &[target("main")])).unwrap();
1584 let Some(PrResolution::Pr(badge)) = out.get(&target("main")) else {
1585 panic!("expected a badge: {out:?}");
1586 };
1587 assert_eq!(badge.number, 42);
1588 assert!(badge.is_draft);
1589 assert_eq!(badge.checks, PrCheckState::Failure);
1590 }
1591
1592 #[test]
1593 fn resolve_with_reports_negatives_alongside_badges_end_to_end() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let (bin, _shim) = fake_gh(
1596 dir.path(),
1597 r#"{"data":{"r0":{
1598 "b0":{
1599 "target":{"oid":"a","statusCheckRollup":null},
1600 "associatedPullRequests":{"nodes":[{"number":7,"isDraft":false,"url":"u"}]}},
1601 "b1":null,
1602 "b2":{
1603 "target":{"oid":"b","statusCheckRollup":null},
1604 "associatedPullRequests":{"nodes":[]}}}}}"#,
1605 0,
1606 );
1607 let targets = vec![target("main"), target("unpushed"), target("quiet")];
1608 let out = retry_on_etxtbsy(|| resolve_with(&bin, &targets)).unwrap();
1609 assert_eq!(out.len(), 3, "{out:?}");
1610 assert!(
1611 matches!(out.get(&target("main")), Some(PrResolution::Pr(b)) if b.number == 7),
1612 "{out:?}"
1613 );
1614 assert_eq!(out.get(&target("unpushed")), Some(&PrResolution::NoPr));
1615 assert_eq!(out.get(&target("quiet")), Some(&PrResolution::NoPr));
1616 }
1617
1618 fn pending_pr(number: u64) -> PrResolution {
1622 PrResolution::Pr(PrBadge {
1623 number,
1624 is_draft: false,
1625 checks: PrCheckState::Pending,
1626 url: "u".into(),
1627 head_oid: String::new(),
1628 })
1629 }
1630
1631 #[test]
1632 fn cache_replace_reports_whether_anything_changed() {
1633 let cache = PrStatusCache::new();
1634 let mut map = HashMap::new();
1635 map.insert(target("f"), pending_pr(1));
1636
1637 assert!(cache.replace(map.clone()));
1639 assert!(!cache.replace(map.clone()));
1642
1643 let mut moved = map.clone();
1645 if let Some(PrResolution::Pr(badge)) = moved.get_mut(&target("f")) {
1646 badge.checks = PrCheckState::Success;
1647 }
1648 assert!(cache.replace(moved));
1649 assert!(cache.replace(HashMap::new()));
1651 assert!(!cache.replace(HashMap::new()));
1652 }
1653
1654 #[test]
1655 fn cache_replace_counts_a_new_negative_as_a_change() {
1656 let cache = PrStatusCache::new();
1657 let mut negatives = HashMap::new();
1658 negatives.insert(target("f"), PrResolution::NoPr);
1659
1660 assert!(cache.replace(negatives.clone()));
1663 assert!(!cache.replace(negatives.clone()));
1665
1666 let mut opened = HashMap::new();
1669 opened.insert(target("f"), pending_pr(1));
1670 assert!(cache.replace(opened));
1671 assert!(cache.replace(negatives));
1672 }
1673
1674 #[test]
1675 fn cache_any_pending_ignores_negative_resolutions() {
1676 let cache = PrStatusCache::new();
1678 let mut map = HashMap::new();
1679 map.insert(target("f"), PrResolution::NoPr);
1680 cache.replace(map);
1681 assert!(!cache.any_pending());
1682 }
1683
1684 #[test]
1685 fn cache_get_and_any_pending() {
1686 let cache = PrStatusCache::new();
1687 assert!(cache.get("rust-works", "omni-dev", "f").is_none());
1688 assert!(!cache.any_pending());
1689
1690 let mut map = HashMap::new();
1691 map.insert(target("f"), pending_pr(1));
1692 cache.replace(map.clone());
1693 assert!(
1694 matches!(
1695 cache.get("rust-works", "omni-dev", "f"),
1696 Some(PrResolution::Pr(b)) if b.number == 1
1697 ),
1698 "expected the cached badge back"
1699 );
1700 assert!(cache.any_pending());
1701 assert!(cache.get("rust-works", "omni-dev", "other").is_none());
1703
1704 if let Some(PrResolution::Pr(badge)) = map.get_mut(&target("f")) {
1705 badge.checks = PrCheckState::Success;
1706 }
1707 cache.replace(map);
1708 assert!(!cache.any_pending());
1709 }
1710
1711 #[test]
1712 fn cache_retain_targets_drops_only_the_vanished_entries() {
1713 let cache = PrStatusCache::new();
1716 let mut map = HashMap::new();
1717 map.insert(target("keep"), pending_pr(1));
1718 map.insert(target("drop"), pending_pr(2));
1719 cache.replace(map);
1720
1721 let keep: HashSet<PrTarget> = std::iter::once(target("keep")).collect();
1722 assert!(cache.retain_targets(&keep), "a target was removed");
1723 assert!(cache.get("rust-works", "omni-dev", "keep").is_some());
1724 assert!(cache.get("rust-works", "omni-dev", "drop").is_none());
1725 assert!(!cache.retain_targets(&keep));
1727 }
1728
1729 #[test]
1730 fn cache_seed_then_entries_round_trips_without_a_bump() {
1731 let cache = PrStatusCache::new();
1734 cache.seed([
1735 (target("f"), pending_pr(7)),
1736 (target("g"), PrResolution::NoPr),
1737 ]);
1738 assert!(matches!(
1739 cache.get("rust-works", "omni-dev", "f"),
1740 Some(PrResolution::Pr(b)) if b.number == 7
1741 ));
1742 assert!(matches!(
1743 cache.get("rust-works", "omni-dev", "g"),
1744 Some(PrResolution::NoPr)
1745 ));
1746 let mut entries = cache.entries();
1747 entries.sort_by(|a, b| a.0.cmp(&b.0));
1748 assert_eq!(entries.len(), 2);
1749 assert_eq!(entries[0].0, target("f"));
1750 assert_eq!(entries[1].0, target("g"));
1751 }
1752
1753 #[test]
1756 fn merge_query_asks_for_the_enqueue_fields() {
1757 let (query, index) = build_merge_query(&[target("main")]).unwrap();
1758 for field in [
1760 "id",
1761 "headRefOid",
1762 "mergeStateStatus",
1763 "mergeQueueEntry{ state }",
1764 ] {
1765 assert!(query.contains(field), "missing {field}: {query}");
1766 }
1767 assert!(
1769 query.contains("associatedPullRequests(first:1, states:OPEN)"),
1770 "{query}"
1771 );
1772 assert!(query.contains("checkSuite{ status }"), "{query}");
1773 assert!(!query.contains("rateLimit"), "{query}");
1775 assert_eq!(index.len(), 1);
1776 }
1777
1778 #[test]
1779 fn merge_query_is_none_without_targets() {
1780 assert!(build_merge_query(&[]).is_none());
1781 }
1782
1783 #[test]
1784 fn parse_merge_response_reads_pr_facts_and_already_queued() {
1785 let (_, index) = build_merge_query(&[target("ready"), target("queued")]).unwrap();
1786 let body = json!({"data":{"r0":{
1788 "b0":{
1789 "target":{"oid":"sha-ready","statusCheckRollup":{"contexts":{"nodes":[
1790 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1791 ]}}},
1792 "associatedPullRequests":{"nodes":[{
1793 "id":"PR_ready","number":10,"isDraft":false,
1794 "url":"https://github.com/rust-works/omni-dev/pull/10",
1795 "headRefOid":"sha-ready","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1796 }]}
1797 },
1798 "b1":{
1799 "target":{"oid":"sha-q","statusCheckRollup":null},
1800 "associatedPullRequests":{"nodes":[{
1801 "id":"PR_queued","number":11,"isDraft":true,
1802 "url":"https://github.com/rust-works/omni-dev/pull/11",
1803 "headRefOid":"sha-q","mergeStateStatus":"BLOCKED",
1804 "mergeQueueEntry":{"state":"QUEUED"}
1805 }]}
1806 }
1807 }}});
1808 let out = parse_merge_response(&body, &index);
1809
1810 let ready = out.get(&target("ready")).expect("ready resolved");
1811 assert_eq!(ready.pr_id, "PR_ready");
1812 assert_eq!(ready.number, 10);
1813 assert_eq!(ready.head_oid, "sha-ready");
1814 assert_eq!(ready.merge_state.as_deref(), Some("CLEAN"));
1815 assert!(!ready.is_draft);
1816 assert!(!ready.already_queued);
1817 assert_eq!(ready.checks, PrCheckState::Success);
1818
1819 let queued = out.get(&target("queued")).expect("queued resolved");
1820 assert!(queued.is_draft);
1821 assert!(queued.already_queued);
1822 assert_eq!(queued.merge_state.as_deref(), Some("BLOCKED"));
1823 assert_eq!(queued.checks, PrCheckState::None);
1825 }
1826
1827 #[test]
1828 fn parse_merge_response_treats_absent_or_prless_refs_as_skips() {
1829 let (_, index) = build_merge_query(&[target("null-ref"), target("no-pr")]).unwrap();
1830 let body = json!({"data":{"r0":{
1831 "b0": null,
1833 "b1": {
1835 "target":{"oid":"x","statusCheckRollup":null},
1836 "associatedPullRequests":{"nodes":[]}
1837 }
1838 }}});
1839 let out = parse_merge_response(&body, &index);
1840 assert!(out.is_empty(), "{out:?}");
1843 }
1844
1845 #[test]
1846 fn merge_info_requires_an_id_and_number() {
1847 let node = json!({
1850 "target":{"oid":"x","statusCheckRollup":null},
1851 "associatedPullRequests":{"nodes":[{"number":9,"url":"u"}]}
1852 });
1853 assert!(merge_info_from_ref(&node).is_none());
1854 }
1855
1856 #[test]
1857 fn enqueue_error_message_joins_or_falls_back() {
1858 let errs = vec![
1859 json!({"message":"Merge queue is not enabled"}),
1860 json!({"message":"Pull request is not mergeable"}),
1861 ];
1862 assert_eq!(
1863 enqueue_error_message(&errs),
1864 "Merge queue is not enabled; Pull request is not mergeable"
1865 );
1866 assert_eq!(
1868 enqueue_error_message(&[json!({"code":"X"})]),
1869 "enqueue rejected by GitHub"
1870 );
1871 }
1872
1873 #[test]
1874 fn parse_merge_response_tolerates_a_missing_data_block() {
1875 let (_, index) = build_merge_query(&[target("main")]).unwrap();
1878 assert!(parse_merge_response(&json!({}), &index).is_empty());
1879 }
1880
1881 #[test]
1882 fn resolve_merge_targets_asks_nothing_for_no_targets() {
1883 let out = resolve_merge_targets(Path::new("/no/such/gh/xyzzy"), &[]).unwrap();
1885 assert!(out.is_empty());
1886 }
1887
1888 #[test]
1889 fn resolve_merge_targets_reads_a_reply_end_to_end() {
1890 let dir = tempfile::tempdir().unwrap();
1891 let (bin, _shim) = fake_gh(
1892 dir.path(),
1893 r#"{"data":{"r0":{"b0":{
1894 "target":{"oid":"a","statusCheckRollup":{"contexts":{"nodes":[
1895 {"__typename":"CheckRun","status":"COMPLETED","conclusion":"SUCCESS"}
1896 ]}}},
1897 "associatedPullRequests":{"nodes":[{
1898 "id":"PR_1","number":42,"isDraft":false,"url":"u42",
1899 "headRefOid":"a","mergeStateStatus":"CLEAN","mergeQueueEntry":null
1900 }]}}}}}"#,
1901 0,
1902 );
1903 let out = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap();
1904 let info = out.get(&target("main")).expect("resolved");
1905 assert_eq!(info.pr_id, "PR_1");
1906 assert_eq!(info.number, 42);
1907 assert_eq!(info.head_oid, "a");
1908 assert_eq!(info.merge_state.as_deref(), Some("CLEAN"));
1909 assert!(!info.already_queued);
1910 assert_eq!(info.checks, PrCheckState::Success);
1911 }
1912
1913 #[test]
1914 fn resolve_merge_targets_errors_on_a_graphql_errors_body() {
1915 let dir = tempfile::tempdir().unwrap();
1916 let (bin, _shim) = fake_gh(
1917 dir.path(),
1918 r#"{"errors":[{"message":"Something is wrong"}]}"#,
1919 0,
1920 );
1921 let err = retry_on_etxtbsy(|| resolve_merge_targets(&bin, &[target("main")])).unwrap_err();
1922 assert!(format!("{err:#}").contains("returned errors"), "{err:#}");
1923 }
1924
1925 #[test]
1926 fn enqueue_pull_request_reports_queued_on_success() {
1927 let dir = tempfile::tempdir().unwrap();
1928 let (bin, _shim) = fake_gh(
1929 dir.path(),
1930 r#"{"data":{"enqueuePullRequest":{"mergeQueueEntry":{"state":"QUEUED"}}}}"#,
1931 0,
1932 );
1933 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1934 assert_eq!(out, EnqueueOutcome::Queued(Some("QUEUED".to_string())));
1935 }
1936
1937 #[test]
1938 fn enqueue_pull_request_maps_a_nonzero_exit_to_rejected() {
1939 let dir = tempfile::tempdir().unwrap();
1942 let (bin, _shim) = fake_gh(
1943 dir.path(),
1944 r#"{"errors":[{"message":"Merge queue is not enabled on this repository"}]}"#,
1945 1,
1946 );
1947 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1948 assert_eq!(
1949 out,
1950 EnqueueOutcome::Rejected("Merge queue is not enabled on this repository".to_string())
1951 );
1952 }
1953
1954 #[test]
1955 fn enqueue_pull_request_maps_a_200_with_errors_to_rejected() {
1956 let dir = tempfile::tempdir().unwrap();
1959 let (bin, _shim) = fake_gh(
1960 dir.path(),
1961 r#"{"errors":[{"message":"Pull request is not mergeable"}]}"#,
1962 0,
1963 );
1964 let out = retry_on_etxtbsy(|| enqueue_pull_request(&bin, "PR_1")).unwrap();
1965 assert_eq!(
1966 out,
1967 EnqueueOutcome::Rejected("Pull request is not mergeable".to_string())
1968 );
1969 }
1970}