1use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{ContestedHandoff, LandApproval, MergeOutcome, RunState, RunStatus, tail};
58
59pub const POLL: Duration = Duration::from_secs(30);
65
66pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86const LOG_TAIL: usize = 4_000;
89
90const MAX_LOGS: usize = 3;
93
94pub const MARKER: &str = "<!-- magi:land -->";
100
101const NOT_A_REVIEW: [&str; 3] = [
110 "skip review by coderabbit.ai",
111 "summarize by coderabbit.ai",
112 "<!-- tips_start -->",
113];
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118 Open,
120 Merged,
122 Closed,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129 Pending,
131 Green,
134 Red,
136 Unknown,
138}
139
140impl PrLifecycle {
141 pub fn as_str(self) -> &'static str {
143 match self {
144 Self::Open => "open",
145 Self::Merged => "merged",
146 Self::Closed => "closed",
147 }
148 }
149}
150
151impl Checks {
152 pub fn as_str(self) -> &'static str {
154 match self {
155 Self::Pending => "pending",
156 Self::Green => "green",
157 Self::Red => "red",
158 Self::Unknown => "unknown",
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166 pub author: String,
168 pub path: Option<String>,
170 pub line: Option<u64>,
172 pub body: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179 pub url: String,
181 pub number: u64,
183 pub state: PrLifecycle,
185 pub checks: Checks,
187 pub failing: Vec<String>,
189 pub review_comments: Vec<ReviewComment>,
191 pub blocking: Blocking,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209 No,
211 Yes,
213 Conflict,
215 Unsaid,
219}
220
221impl Blocking {
222 fn of(raw: &str) -> Self {
224 match raw.to_ascii_uppercase().as_str() {
225 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228 "DIRTY" => Self::Conflict,
229 "" | "UNKNOWN" => Self::Unsaid,
230 _ => Self::Yes,
232 }
233 }
234
235 #[must_use]
237 pub fn stops_a_merge(self) -> bool {
238 !matches!(self, Self::No)
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245 Wait,
247 Rebase,
255 Fix {
257 reason: String,
259 },
260 Merge,
262 Done {
264 merged: bool,
266 },
267 GiveUp {
269 reason: String,
271 },
272}
273
274pub(crate) fn merged_after_all(
296 argv: &[String],
297 stderr: &str,
298 after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300 if after? != PrLifecycle::Merged {
301 return None;
302 }
303 Some(MergeOutcome {
304 mode: MergeMode::Pr,
305 ok: true,
306 detail: format!(
307 "gh {} (the command reported `{}`, but the pull request is merged)",
308 argv.join(" "),
309 stderr.trim()
310 ),
311 empty: false,
312 })
313}
314
315pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
335 decide_with(pr, round, budget, waited, CiExpectation::Expected)
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum CiExpectation {
341 Expected,
344 Absent,
349}
350
351pub fn decide_with(
356 pr: &PrState,
357 round: usize,
358 budget: usize,
359 waited: Duration,
360 ci: CiExpectation,
361) -> Step {
362 match pr.state {
363 PrLifecycle::Merged => return Step::Done { merged: true },
364 PrLifecycle::Closed => return Step::Done { merged: false },
365 PrLifecycle::Open => {}
366 }
367
368 if pr.blocking == Blocking::Conflict {
371 return Step::Rebase;
372 }
373
374 if ci == CiExpectation::Absent {
375 return Step::Merge;
376 }
377
378 let spent = round >= budget;
379 match pr.checks {
380 Checks::Pending => Step::Wait,
381 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
382 Checks::Unknown => Step::GiveUp {
383 reason: format!(
384 "no check status is readable on the pull request after {} minute(s); \
385 refusing to merge on a guess",
386 CHECKS_GRACE.as_secs() / 60
387 ),
388 },
389 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
396 Checks::Red => {
397 let what = format!(
398 "{} check(s) failing: {}",
399 pr.failing.len(),
400 pr.failing.join(", ")
401 );
402 if spent {
403 Step::GiveUp {
404 reason: format!("{what} — still red after {budget} fix round(s)"),
405 }
406 } else {
407 Step::Fix { reason: what }
408 }
409 }
410 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
411 Checks::Green => {
412 let what = format!(
413 "checks are green but {} review comment(s) are unresolved: {}",
414 pr.review_comments.len(),
415 authors(&pr.review_comments)
416 );
417 if spent {
418 Step::GiveUp {
419 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
420 }
421 } else {
422 Step::Fix { reason: what }
423 }
424 }
425 }
426}
427
428fn authors(comments: &[ReviewComment]) -> String {
430 let mut seen: Vec<&str> = Vec::new();
431 for c in comments {
432 if !seen.contains(&c.author.as_str()) {
433 seen.push(&c.author);
434 }
435 }
436 seen.join(", ")
437}
438
439pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
443 vec![
444 "pr".to_owned(),
445 "merge".to_owned(),
446 number.to_string(),
447 "--squash".to_owned(),
448 "--delete-branch".to_owned(),
449 "--subject".to_owned(),
450 subject.to_owned(),
451 ]
452}
453
454pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
460 let mut argv = merge_argv(number, subject);
461 argv.push("--match-head-commit".to_owned());
462 argv.push(head.to_owned());
463 argv
464}
465
466pub fn disable_automerge_argv(number: u64) -> Vec<String> {
471 ["pr", "merge", &number.to_string(), "--disable-auto"]
472 .map(str::to_owned)
473 .to_vec()
474}
475
476fn direct_merge_is_safe(
482 fresh: Option<&Seen>,
483 approved_head: &str,
484 shown: &BTreeSet<String>,
485 round: usize,
486 budget: usize,
487 waited: Duration,
488) -> bool {
489 let Some(fresh) = fresh else {
490 return false;
491 };
492 if fresh.pr.state != PrLifecycle::Open {
493 return false;
494 }
495 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
496 return false;
497 };
498 if !bound.eq_ignore_ascii_case(approved_head) {
499 return false;
500 }
501 let mut pr = fresh.pr.clone();
502 pr.review_comments.retain(|c| !shown.contains(&c.body));
503 decide(&pr, round, budget, waited) == Step::Merge
504}
505
506fn waiting_on(
513 merge_state: &str,
514 contexts: &[CheckInfo],
515 required_set: Option<&BTreeSet<String>>,
516) -> String {
517 let state = if merge_state.is_empty() {
518 "unknown"
519 } else {
520 merge_state
521 };
522 let tag = |c: &CheckInfo| match c.verdict {
523 Verdict::Fail => "failed",
524 _ => "pending",
525 };
526 let unsettled: Vec<&CheckInfo> = contexts
527 .iter()
528 .filter(|c| c.verdict != Verdict::Pass)
529 .collect();
530 let required: Vec<String> = unsettled
531 .iter()
532 .filter(|c| c.required == Some(true))
533 .map(|c| format!("{} ({})", c.label, tag(c)))
534 .collect();
535 let unknown: Vec<String> = unsettled
536 .iter()
537 .filter(|c| c.required.is_none())
538 .map(|c| format!("{} ({})", c.label, tag(c)))
539 .collect();
540 let never: Vec<&str> = required_set
544 .map(|set| {
545 set.iter()
546 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
547 .map(String::as_str)
548 .collect()
549 })
550 .unwrap_or_default();
551 let mut out = format!("merge state: {state}");
552 if !never.is_empty() {
553 let _ = write!(
554 out,
555 "; required checks never reported: {}",
556 never.join(", ")
557 );
558 }
559 if !required.is_empty() {
560 let _ = write!(
561 out,
562 "; required checks not passing: {}",
563 required.join(", ")
564 );
565 }
566 if !unknown.is_empty() {
567 let _ = write!(
568 out,
569 "; whether these are required could not be read, so they may be: {}",
570 unknown.join(", ")
571 );
572 }
573 if required.is_empty() && unknown.is_empty() && never.is_empty() {
574 if required_set.is_some() {
575 out.push_str(
576 "; no required check is pending, failing or unreported, so GitHub is probably \
577 waiting for a review or another branch rule",
578 );
579 } else {
580 out.push_str(
581 "; the required check list could not be read, so a required check that was \
582 never reported cannot be ruled out",
583 );
584 }
585 }
586 out
587}
588
589pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
596 let title = pr_title.trim();
597 if !title.is_empty() && !title.starts_with("magi: candidate") {
598 return title.to_owned();
599 }
600 let first = instruction
601 .lines()
602 .map(str::trim)
603 .find(|l| !l.is_empty())
604 .unwrap_or("magi: land the winning candidate");
605 first.trim_start_matches(['#', ' ']).to_owned()
606}
607
608pub const APPROVE: &str = "merge";
610
611pub const HOLD: &str = "hold";
613
614pub fn merge_intent(message: &str, quote: &str) -> bool {
625 let quote = quote.trim();
626 if quote.is_empty() {
627 return false;
628 }
629 if !message.contains(quote) {
630 return false;
631 }
632 let lower = quote.to_lowercase();
633 if !(lower.contains("merge") || quote.contains("マージ")) {
634 return false;
635 }
636 unhedged(message, quote)
637}
638
639pub fn unhedged(message: &str, quote: &str) -> bool {
648 let quote = quote.trim();
649 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
650}
651
652fn hedged(text: &str) -> bool {
656 const WORDS: &[&str] = &[
657 "maybe",
658 "probably",
659 "perhaps",
660 "might",
661 "if",
662 "unless",
663 "not",
664 "no",
665 "nope",
666 "stop",
667 "dont",
668 "abort",
669 "revert",
670 "undo",
671 "never",
672 "wait",
673 "hold",
674 "cancel",
675 "but",
676 "think",
677 "guess",
678 "suppose",
679 "unsure",
680 "yet",
681 "except",
682 "only",
683 "cannot",
684 "should",
685 "once",
686 "provided",
687 "providing",
688 "when",
689 "whenever",
690 "after",
691 "until",
692 "before",
693 "assuming",
694 "given",
695 "while",
696 "whether",
697 "depending",
698 "pending",
699 ];
700 const JA: &[&str] = &[
701 "かも",
702 "たぶん",
703 "多分",
704 "なら",
705 "たら",
706 "ちょっと待",
707 "しないで",
708 "しない",
709 "保留",
710 "まだ",
711 "ただし",
712 "やめ",
713 "いや",
714 "止め",
715 "だめ",
716 "ダメ",
717 "じゃない",
718 "ではない",
719 "ですか",
720 "かな",
721 "でしょう",
722 "思う",
723 "でも",
724 "けど",
725 "ただ",
726 "次第",
727 "場合",
728 "限り",
729 "条件",
730 "とき",
731 "まで",
732 "後で",
733 ];
734 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
735 return true;
736 }
737 text.to_lowercase()
738 .replace('\u{2019}', "'")
739 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
740 .filter(|w| !w.is_empty())
741 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
742}
743
744fn retracts(message: &str) -> bool {
747 let lower = message.to_lowercase();
748 [
749 "やっぱ",
750 "待って",
751 "撤回",
752 "never mind",
753 "actually",
754 "on second thought",
755 ]
756 .iter()
757 .any(|w| lower.contains(w))
758 || lower
759 .split(|c: char| !c.is_ascii_alphabetic())
760 .any(|w| w == "wait")
761}
762
763pub const APPROVAL_NODE: &str = "land-approval";
769
770pub const DIFF_MAX_LINES: usize = 400;
778
779#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub enum Approval {
782 Merge,
784 Hold,
786}
787
788pub fn approval(answer: Option<&str>) -> Approval {
796 match answer {
797 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
798 _ => Approval::Hold,
799 }
800}
801
802#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804enum ApprovalGate {
805 Approved,
807 Held,
810 Pending,
812}
813
814fn esc(s: &str) -> String {
824 let mut out = String::with_capacity(s.len());
825 for c in s.chars() {
826 match c {
827 '&' => out.push_str("&"),
828 '<' => out.push_str("<"),
829 '>' => out.push_str(">"),
830 '"' => out.push_str("""),
831 '\'' => out.push_str("'"),
832 _ => out.push(c),
833 }
834 }
835 out
836}
837
838#[derive(Debug, Clone, PartialEq, Eq)]
840struct StatRow {
841 path: String,
842 added: Option<u64>,
844 removed: Option<u64>,
845}
846
847impl StatRow {
848 fn churn(&self) -> u64 {
851 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
852 }
853}
854
855fn parse_numstat(numstat: &str) -> Vec<StatRow> {
861 let mut rows: Vec<StatRow> = numstat
862 .lines()
863 .filter_map(|line| {
864 let mut parts = line.splitn(3, '\t');
865 let added = parts.next()?.trim();
866 let removed = parts.next()?.trim();
867 let path = parts.next()?.trim();
868 if path.is_empty() {
869 return None;
870 }
871 Some(StatRow {
872 path: path.to_owned(),
873 added: added.parse().ok(),
874 removed: removed.parse().ok(),
875 })
876 })
877 .collect();
878 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
881 rows
882}
883
884fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
893 if line.starts_with("+++") || line.starts_with("---") {
894 (" ", "color:#57606a;font-weight:600", line)
895 } else if let Some(body) = line.strip_prefix('+') {
896 ("+", "background:#e6ffec;color:#0a3622", body)
897 } else if let Some(body) = line.strip_prefix('-') {
898 ("-", "background:#ffebe9;color:#5c1a17", body)
899 } else if line.starts_with("@@") {
900 ("~", "background:#eef2ff;color:#3730a3", line)
901 } else if let Some(body) = line.strip_prefix(' ') {
902 (" ", "", body)
903 } else {
904 (" ", "color:#57606a;font-weight:600", line)
905 }
906}
907
908struct Words {
917 html_lang: &'static str,
918 task: &'static str,
919 what_changed: &'static str,
920 review_verdict: &'static str,
921 reviewer: &'static str,
922 reviewer_no_answer: &'static str,
923 checks: &'static str,
924 nothing_failing: &'static str,
925 files_changed: &'static str,
926 commits: &'static str,
927 no_commits: &'static str,
928 comments: &'static str,
929 no_comments: &'static str,
930 diff: &'static str,
931 truncated: &'static str,
932 lands_as: &'static str,
933}
934
935const EN: Words = Words {
936 html_lang: "en",
937 task: "Task",
938 what_changed: "What changed",
939 review_verdict: "Review verdict",
940 reviewer: "Reviewer",
941 reviewer_no_answer: "produced no answer",
942 checks: "Checks",
943 nothing_failing: "Nothing failing.",
944 files_changed: "file(s) changed",
945 commits: "Commits being squashed",
946 no_commits: "No commit subjects could be read from the branch.",
947 comments: "Review comments",
948 no_comments: "Nothing outstanding at this observation.",
949 diff: "Diff",
950 truncated: "Truncated",
951 lands_as: "They land as one commit titled",
952};
953
954const JA: Words = Words {
955 html_lang: "ja",
956 task: "タスク",
957 what_changed: "変更内容",
958 review_verdict: "レビューの結論",
959 reviewer: "レビュアー",
960 reviewer_no_answer: "回答なし",
961 checks: "チェック",
962 nothing_failing: "失敗しているものはありません。",
963 files_changed: "ファイル変更",
964 commits: "squash されるコミット",
965 no_commits: "ブランチからコミット件名を読めませんでした。",
966 comments: "レビューコメント",
967 no_comments: "この時点で未対応のものはありません。",
968 diff: "差分",
969 truncated: "省略",
970 lands_as: "これらは次の件名の1コミットとして入ります:",
971};
972
973impl Words {
974 fn lands_as_tail(&self) -> &'static str {
978 if self.html_lang == "ja" {
979 "。この件名も承認の対象です。"
980 } else {
981 ", which you are approving too."
982 }
983 }
984
985 fn approval_summary(&self, number: u64, subject: &str) -> String {
987 if self.html_lang == "ja" {
988 format!("プルリクエスト #{number} をマージ: {subject}")
989 } else {
990 format!("merge pull request #{number}: {subject}")
991 }
992 }
993
994 fn approval_detail(
996 &self,
997 url: &str,
998 base: &str,
999 subject: &str,
1000 contested: Option<&ContestedHandoff>,
1001 ) -> String {
1002 let body = if self.html_lang == "ja" {
1003 format!(
1004 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1005 できる状態です。差分の要約・パッチ・squash されるコミットは\
1006 下のパネルにあります。"
1007 )
1008 } else {
1009 format!(
1010 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1011 The panel holds the diffstat, the patch and the commits being squashed."
1012 )
1013 };
1014 match contested {
1015 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1016 None => body,
1017 }
1018 }
1019
1020 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1023 const SHOWN: usize = 5;
1024 const TITLE_CHARS: usize = 100;
1025 let ja = self.html_lang == "ja";
1026 let mut out = if ja {
1027 format!(
1028 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1029 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1030 )
1031 } else {
1032 format!(
1033 "{url} is held for approval although merge approvals are off: the \
1034 review ended with blocking findings still open and a reviewer \
1035 voting reject.\n"
1036 )
1037 };
1038 for f in c.findings.iter().take(SHOWN) {
1039 let at = match (&f.file, f.line) {
1040 (Some(file), Some(line)) => format!("{file}:{line}"),
1041 (Some(file), None) => file.clone(),
1042 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1043 };
1044 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1045 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1046 }
1047 if c.findings.len() > SHOWN {
1048 let more = c.findings.len() - SHOWN;
1049 let _ = writeln!(
1050 out,
1051 "{}",
1052 if ja {
1053 format!("- ほか {more} 件")
1054 } else {
1055 format!("- and {more} more")
1056 }
1057 );
1058 }
1059 let seats: Vec<String> = c
1060 .rejecters
1061 .iter()
1062 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1063 .collect();
1064 let _ = write!(
1065 out,
1066 "{} {}",
1067 if ja {
1068 "却下したレビュアー:"
1069 } else {
1070 "Rejected by reviewer:"
1071 },
1072 seats.join(", ")
1073 );
1074 out
1075 }
1076
1077 fn truncated_note(
1079 &self,
1080 omitted: usize,
1081 total: usize,
1082 shown: usize,
1083 where_: &str,
1084 base: &str,
1085 head: &str,
1086 ) -> String {
1087 if self.html_lang == "ja" {
1088 format!(
1089 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1090 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1091 プルリクエストにあります。"
1092 )
1093 } else {
1094 format!(
1095 "{omitted} of {total} diff lines omitted after the first {shown}. \
1096 The whole patch is in <code>{where_}</code> \
1097 (<code>git diff {base}...{head}</code>) and on the pull request."
1098 )
1099 }
1100 }
1101}
1102
1103fn words(language: &str) -> &'static Words {
1106 if crate::lang::is_japanese(language) {
1107 &JA
1108 } else {
1109 &EN
1110 }
1111}
1112
1113pub fn approval_panel(
1125 state: &RunState,
1126 pr: &PrState,
1127 diffstat: &str,
1128 diff: &str,
1129 commits: &[String],
1130 subject: &str,
1131) -> String {
1132 let rows = parse_numstat(diffstat);
1133 let w = words(&state.config.graph.language);
1134 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1135
1136 let _ = writeln!(
1137 h,
1138 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1139 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1140 w.html_lang
1141 );
1142 let _ = writeln!(
1143 h,
1144 "<title>merge #{} — {}</title>\n</head>",
1145 pr.number,
1146 esc(subject)
1147 );
1148 h.push_str(
1149 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1150 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1151 word-break:break-word\">\n",
1152 );
1153
1154 let _ = writeln!(
1156 h,
1157 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1158 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1159 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1160 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1161 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
1162 pr.number,
1163 esc(&state.base_branch),
1164 esc(subject),
1165 esc(&state.id),
1166 esc(&pr.url),
1167 esc(&pr.url),
1168 );
1169
1170 let _ = writeln!(
1173 h,
1174 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1175 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1176 w.task,
1177 esc(&state.instruction)
1178 );
1179
1180 if let Some(summary) = state
1182 .winner()
1183 .map(|c| c.summary.as_str())
1184 .filter(|s| !s.is_empty())
1185 {
1186 let _ = writeln!(
1187 h,
1188 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1189 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1190 w.what_changed,
1191 esc(summary)
1192 );
1193 }
1194
1195 if let Some(round) = state.reviews.last() {
1198 let _ = writeln!(
1199 h,
1200 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1201 w.review_verdict
1202 );
1203 for r in &round.reviews {
1204 let body = match &r.failed {
1211 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1212 None => esc(&r.summary),
1213 };
1214 let _ = writeln!(
1215 h,
1216 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1217 border-radius:6px\">\
1218 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1219 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1220 w.reviewer,
1221 r.reviewer,
1222 esc(&r.agent),
1223 body,
1224 );
1225 }
1226 }
1227
1228 let _ = writeln!(
1229 h,
1230 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1231 w.checks,
1232 esc(pr.checks.as_str())
1233 );
1234 if pr.failing.is_empty() {
1235 let _ = writeln!(
1236 h,
1237 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1238 w.nothing_failing
1239 );
1240 } else {
1241 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1242 for f in &pr.failing {
1243 let _ = writeln!(h, "<li>{}</li>", esc(f));
1244 }
1245 h.push_str("</ul>\n");
1246 }
1247
1248 let _ = writeln!(
1251 h,
1252 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1253 rows.len(),
1254 w.files_changed
1255 );
1256 h.push_str(
1257 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1258 <thead><tr>\
1259 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1260 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1261 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1262 </th></tr></thead>\n<tbody>\n",
1263 );
1264 let mut total_added = 0u64;
1265 let mut total_removed = 0u64;
1266 for r in &rows {
1267 total_added += r.added.unwrap_or(0);
1268 total_removed += r.removed.unwrap_or(0);
1269 let cell = |n: Option<u64>| match n {
1270 Some(n) => n.to_string(),
1271 None => "bin".to_owned(),
1272 };
1273 let _ = writeln!(
1274 h,
1275 "<tr>\
1276 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1277 font-family:ui-monospace,monospace\">{}</td>\
1278 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1279 color:#0a3622\">{}</td>\
1280 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1281 color:#5c1a17\">{}</td></tr>",
1282 esc(&r.path),
1283 cell(r.added),
1284 cell(r.removed),
1285 );
1286 }
1287 let _ = writeln!(
1288 h,
1289 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1290 <td style=\"padding:4px 2px\">total</td>\
1291 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1292 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1293 </tr></tfoot>\n</table>"
1294 );
1295
1296 let _ = writeln!(
1298 h,
1299 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1300 w.commits
1301 );
1302 if commits.is_empty() {
1303 h.push_str(&format!(
1304 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1305 w.no_commits
1306 ));
1307 } else {
1308 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1309 for c in commits {
1310 let _ = writeln!(h, "<li>{}</li>", esc(c));
1311 }
1312 h.push_str("</ol>\n");
1313 }
1314 let _ = writeln!(
1315 h,
1316 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1317 w.lands_as,
1318 esc(subject),
1319 w.lands_as_tail()
1320 );
1321
1322 let _ = writeln!(
1324 h,
1325 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1326 w.comments
1327 );
1328 if pr.review_comments.is_empty() {
1329 h.push_str(&format!(
1330 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1331 w.no_comments
1332 ));
1333 } else {
1334 for c in &pr.review_comments {
1335 let anchor = match (&c.path, c.line) {
1336 (Some(p), Some(l)) => format!("{p}:{l}"),
1337 (Some(p), None) => p.clone(),
1338 _ => "pull request thread".to_owned(),
1339 };
1340 let _ = writeln!(
1341 h,
1342 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1343 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1344 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1345 esc(&c.author),
1346 esc(&anchor),
1347 esc(&tail(&c.body, 800)),
1348 );
1349 }
1350 }
1351
1352 let total = diff.lines().count();
1354 let shown = total.min(DIFF_MAX_LINES);
1355 let _ = writeln!(
1356 h,
1357 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1358 w.diff
1359 );
1360 h.push_str(
1361 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1362 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1363 );
1364 for line in diff.lines().take(shown) {
1365 let (gutter, style, body) = diff_row(line);
1366 let _ = writeln!(
1367 h,
1368 "<div style=\"display:flex;{style}\">\
1369 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1370 border-right:1px solid #d0d7de\">{gutter}</span>\
1371 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1372 esc(body),
1373 );
1374 }
1375 h.push_str("</div>\n");
1376 if total > shown {
1377 let omitted = total - shown;
1378 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1379 let where_ = state.winner().map_or_else(
1380 || state.repo.display().to_string(),
1381 |w| w.worktree.display().to_string(),
1382 );
1383 let _ = writeln!(
1384 h,
1385 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1386 font-size:13px\">{}: {}</p>",
1387 w.truncated,
1388 w.truncated_note(
1389 omitted,
1390 total,
1391 shown,
1392 &esc(&where_),
1393 &esc(&state.base_branch),
1394 &esc(head),
1395 ),
1396 );
1397 }
1398
1399 h.push_str("</body>\n</html>\n");
1400 h
1401}
1402
1403fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1407 if state.config.graph.hold_contested_merge {
1408 state.contested_handoff.clone()
1409 } else {
1410 None
1411 }
1412}
1413
1414pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1423 let mut s = format!(
1424 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1425 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1426 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1427 request open. Silence is a hold: the owner not answering never merges. Only \
1428 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1429 own words, merges. Hedged, conditional, negated or questioning wording \
1430 (\"maybe\", \"probably\", \"if\", \"いいかも\", \"たぶん\") is not a decision \
1431 and stays a hold.\n\n\
1432 This brief is a snapshot from when you were attached: check `magi show {run}` \
1433 and `gh pr view` (read-only) before telling the owner anything current. \
1434 You run with permission to write the question record, and what keeps you \
1435 from touching anything else is this brief and your instructions - so do \
1436 not change files, branches or the pull request.",
1437 run = q.run
1438 );
1439 let Some(state) = state else {
1440 s.push_str(
1441 "\n\nThe run's record could not be read, so the pull request, the panel \
1442 summary and any contested findings are not known to you beyond the \
1443 question's own text. Say so to the owner rather than guessing.",
1444 );
1445 return s;
1446 };
1447 if let Some(pr) = &state.pr {
1448 s.push_str(&format!(
1449 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1450 pr.number, pr.url, pr.state
1451 ));
1452 }
1453 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1454 if let Some(w) = state.winner() {
1455 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1456 }
1457 match contested_to_ask(state) {
1458 Some(c) => {
1459 s.push_str(
1460 "\n\nThis question was filed although merge approvals are off, because \
1461 the review hand-off is contested. Open findings:",
1462 );
1463 for f in &c.findings {
1464 let at = match (&f.file, f.line) {
1465 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1466 (Some(file), None) => format!(" ({file})"),
1467 _ => String::new(),
1468 };
1469 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1470 }
1471 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1472 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1473 }
1474 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1475 }
1476 s
1477}
1478
1479async fn approval_gate(
1499 state: &mut RunState,
1500 pr: &PrState,
1501 subject: &str,
1502 contested: Option<&ContestedHandoff>,
1503 head: &str,
1504) -> Result<ApprovalGate> {
1505 let store = ask::Questions::open();
1506 let reusable = state
1511 .land_approval
1512 .as_ref()
1513 .filter(|a| a.head.eq_ignore_ascii_case(head))
1514 .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1515 if reusable.is_none() {
1516 for stale in store
1517 .list()
1518 .into_iter()
1519 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1520 {
1521 let why = "the pull request moved to a different head commit; asked again about it";
1522 if let Err(e) = store.update(&stale.id, |q| {
1523 q.abandon(why);
1524 Ok(())
1525 }) {
1526 tracing::warn!("could not retire the superseded approval question: {e:#}");
1527 }
1528 }
1529 }
1530
1531 let q = match reusable {
1532 Some(q) => q,
1533 None => {
1534 let worktree = match state.winner() {
1535 Some(w) => w.worktree.clone(),
1536 None => state.repo.clone(),
1537 };
1538 let head = if head.is_empty() {
1541 state
1542 .winner()
1543 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1544 } else {
1545 head.to_owned()
1546 };
1547 let remote = &state.config.merge.remote;
1550 let tracking = format!("{remote}/{}", state.base_branch);
1551 let base = if git::rev_exists(&worktree, &tracking).await {
1552 tracking
1553 } else {
1554 String::new()
1555 };
1556 let range = format!("{base}...{head}");
1557 let numstat = if base.is_empty() {
1561 String::new()
1562 } else {
1563 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1564 .await
1565 .map(|o| o.stdout)
1566 .unwrap_or_default()
1567 };
1568 let diff = if base.is_empty() {
1569 String::new()
1570 } else {
1571 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1572 };
1573 let commits: Vec<String> = if base.is_empty() {
1574 Vec::new()
1575 } else {
1576 git::git_raw(
1577 &worktree,
1578 &[
1579 "log",
1580 "--reverse",
1581 "--format=%s",
1582 &format!("{base}..{head}"),
1583 ],
1584 )
1585 .await
1586 .map(|o| o.stdout)
1587 .unwrap_or_default()
1588 .lines()
1589 .filter(|l| !l.trim().is_empty())
1590 .map(str::to_owned)
1591 .collect()
1592 };
1593
1594 let w = words(&state.config.graph.language);
1595 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1596 let mut fresh = ask::Question::new(
1597 state.id.clone(),
1598 APPROVAL_NODE.to_owned(),
1599 "land".to_owned(),
1600 w.approval_summary(pr.number, subject),
1601 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1602 vec![APPROVE.to_owned(), HOLD.to_owned()],
1603 );
1604 store
1605 .put_panel(&mut fresh, &html, &[])
1606 .context("write the merge approval panel")?;
1607 store
1608 .put(&mut fresh)
1609 .context("file the merge approval question")?;
1610 state.land_approval = Some(LandApproval {
1611 question: fresh.id.clone(),
1612 head: head.clone(),
1613 });
1614 state.event(
1615 "land",
1616 format!("asking for merge approval ({})", fresh.short()),
1617 );
1618 state.save()?;
1619 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1620 tracing::warn!(
1624 "could not notify about merge approval question {}: {e:#} - \
1625 the web UI is the only surface for it now",
1626 fresh.short()
1627 );
1628 }
1629 fresh
1630 }
1631 };
1632
1633 Ok(match q.status {
1634 ask::QuestionStatus::Open => ApprovalGate::Pending,
1635 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1639 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1643 Approval::Merge => ApprovalGate::Approved,
1644 Approval::Hold => ApprovalGate::Held,
1645 },
1646 })
1647}
1648
1649fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1652 let mut failing = Vec::new();
1653 let mut pending = false;
1654 let mut unknown = false;
1655 for check in rollup {
1656 match check.verdict() {
1657 Verdict::Pass => {}
1658 Verdict::Pending => pending = true,
1659 Verdict::Fail => failing.push(check.label()),
1660 Verdict::Unknown => unknown = true,
1661 }
1662 }
1663 let checks = if rollup.is_empty() {
1664 Checks::Unknown
1665 } else if pending {
1666 Checks::Pending
1667 } else if !failing.is_empty() {
1668 Checks::Red
1669 } else if unknown {
1670 Checks::Unknown
1671 } else {
1672 Checks::Green
1673 };
1674 (checks, failing)
1675}
1676
1677pub fn parse_pr(json: &str) -> Result<PrState> {
1680 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1681 let state = match raw.state.to_ascii_uppercase().as_str() {
1682 "OPEN" => PrLifecycle::Open,
1683 "MERGED" => PrLifecycle::Merged,
1684 "CLOSED" => PrLifecycle::Closed,
1685 other => bail!("unknown pull request state `{other}`"),
1686 };
1687
1688 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1689
1690 let mut review_comments = Vec::new();
1691 for r in raw.reviews {
1692 push_if_outstanding(
1693 &mut review_comments,
1694 ReviewComment {
1695 author: r.author.login,
1696 path: None,
1697 line: None,
1698 body: r.body,
1699 },
1700 );
1701 }
1702 for c in raw.comments {
1703 push_if_outstanding(
1704 &mut review_comments,
1705 ReviewComment {
1706 author: c.author.login,
1707 path: None,
1708 line: None,
1709 body: c.body,
1710 },
1711 );
1712 }
1713
1714 Ok(PrState {
1715 url: raw.url,
1716 number: raw.number,
1717 state,
1718 checks,
1719 failing,
1720 review_comments,
1721 blocking: Blocking::of(&raw.merge_state_status),
1722 })
1723}
1724
1725#[derive(Debug, Clone, PartialEq, Eq)]
1727pub(crate) struct CheckView {
1728 pub name: String,
1729 pub verdict: Verdict,
1730 pub run: Option<String>,
1732 pub url: Option<String>,
1733}
1734
1735#[derive(Debug, Clone, PartialEq, Eq)]
1739pub(crate) struct RollupView {
1740 pub url: String,
1741 pub number: u64,
1742 pub state: PrLifecycle,
1743 pub head: String,
1744 pub checks: Vec<CheckView>,
1745}
1746
1747pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1750 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1751 let state = match raw.state.to_ascii_uppercase().as_str() {
1752 "OPEN" => PrLifecycle::Open,
1753 "MERGED" => PrLifecycle::Merged,
1754 "CLOSED" => PrLifecycle::Closed,
1755 other => bail!("unknown pull request state `{other}`"),
1756 };
1757 let checks = raw
1758 .status_check_rollup
1759 .iter()
1760 .map(|c| CheckView {
1761 name: c.label(),
1762 verdict: c.verdict(),
1763 run: c.url().and_then(run_of),
1764 url: c.url().map(str::to_owned),
1765 })
1766 .collect();
1767 Ok(RollupView {
1768 url: raw.url,
1769 number: raw.number,
1770 state,
1771 head: raw.head_ref_oid,
1772 checks,
1773 })
1774}
1775
1776pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1786 let view = gh(
1787 repo,
1788 &[
1789 "pr".to_owned(),
1790 "view".to_owned(),
1791 pr_url.to_owned(),
1792 "--json".to_owned(),
1793 "state".to_owned(),
1794 ],
1795 )
1796 .await?;
1797 if !view.0 {
1798 bail!("gh pr view {pr_url}: {}", view.1);
1799 }
1800 Ok(parse_pr(&view.1)?.state)
1804}
1805
1806#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1810pub struct ExternalMerge {
1811 pub url: String,
1813 pub number: u64,
1815}
1816
1817#[derive(Debug, Deserialize)]
1818#[serde(rename_all = "camelCase")]
1819struct GhMergedPr {
1820 url: String,
1821 number: u64,
1822 merged_at: String,
1823 base_ref_name: String,
1824}
1825
1826fn pick_merged_pr(
1840 json: &str,
1841 base_branch: &str,
1842 created_at: Timestamp,
1843) -> Result<Option<ExternalMerge>> {
1844 let raw: Vec<GhMergedPr> =
1845 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1846 let mut matches: Vec<ExternalMerge> = Vec::new();
1847 for pr in raw {
1848 if pr.base_ref_name != base_branch {
1849 continue;
1850 }
1851 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1852 continue;
1853 };
1854 if merged_at < created_at {
1855 continue;
1856 }
1857 matches.push(ExternalMerge {
1858 url: pr.url,
1859 number: pr.number,
1860 });
1861 }
1862 if matches.len() == 1 {
1863 Ok(matches.pop())
1864 } else {
1865 Ok(None)
1866 }
1867}
1868
1869#[derive(Debug, Clone, PartialEq, Eq)]
1871pub enum OpenPr {
1872 None,
1874 One {
1876 url: String,
1878 title: String,
1880 },
1881 Many(Vec<String>),
1883}
1884
1885#[derive(Debug, Deserialize)]
1886#[serde(rename_all = "camelCase")]
1887struct GhOpenPr {
1888 url: String,
1892 #[serde(default)]
1893 title: String,
1894 base_ref_name: String,
1895}
1896
1897pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1903 let raw: Vec<GhOpenPr> =
1904 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1905 let mut hits: Vec<GhOpenPr> = raw
1906 .into_iter()
1907 .filter(|p| p.base_ref_name == base)
1908 .collect();
1909 Ok(match hits.len() {
1910 0 => OpenPr::None,
1911 1 => {
1912 let p = hits.remove(0);
1913 OpenPr::One {
1914 url: p.url,
1915 title: p.title,
1916 }
1917 }
1918 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1919 })
1920}
1921
1922pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1926 let (ok, out) = gh(
1927 repo,
1928 &[
1929 "pr".to_owned(),
1930 "list".to_owned(),
1931 "--head".to_owned(),
1932 branch.to_owned(),
1933 "--base".to_owned(),
1934 base.to_owned(),
1935 "--state".to_owned(),
1936 "open".to_owned(),
1937 "--json".to_owned(),
1938 "number,url,title,baseRefName".to_owned(),
1939 ],
1940 )
1941 .await?;
1942 if !ok {
1943 bail!("gh pr list failed: {out}");
1944 }
1945 pick_open_pr(&out, base)
1946}
1947
1948#[derive(Debug, Deserialize)]
1949#[serde(rename_all = "camelCase")]
1950struct GhPrHead {
1951 head_ref_name: String,
1952 base_ref_name: String,
1953 state: String,
1954 is_cross_repository: bool,
1957 head_ref_oid: String,
1960}
1961
1962#[derive(Debug, Clone, PartialEq, Eq)]
1964pub struct Refusal {
1965 pub retry: bool,
1968 pub why: String,
1970}
1971
1972impl Refusal {
1973 fn final_(why: String) -> Self {
1974 Self { retry: false, why }
1975 }
1976}
1977
1978pub fn closable(
1987 json: &str,
1988 branch: &str,
1989 base: &str,
1990 verified: &[String],
1991) -> std::result::Result<(), Refusal> {
1992 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1993 retry: true,
1994 why: format!("could not read the pull request ({e})"),
1995 })?;
1996 if pr.head_ref_name != branch {
1997 return Err(Refusal::final_(format!(
1998 "its head is `{}`, not this run's `{branch}`",
1999 pr.head_ref_name
2000 )));
2001 }
2002 if pr.is_cross_repository {
2003 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2004 }
2005 if pr.base_ref_name != base {
2006 return Err(Refusal::final_(format!(
2007 "it targets `{}`, not `{base}`",
2008 pr.base_ref_name
2009 )));
2010 }
2011 if !pr.state.eq_ignore_ascii_case("open") {
2012 return Err(Refusal::final_(format!(
2013 "it is already {}",
2014 pr.state.to_ascii_lowercase()
2015 )));
2016 }
2017 if !verified.contains(&pr.head_ref_oid) {
2021 return Err(Refusal {
2022 retry: true,
2023 why: format!(
2024 "its head {} is not a commit this run checked against the base",
2025 crate::already::short_sha(&pr.head_ref_oid)
2026 ),
2027 });
2028 }
2029 Ok(())
2030}
2031
2032async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2037 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2038 return false;
2039 };
2040 is_forge_url(url.trim())
2041}
2042
2043fn is_forge_url(url: &str) -> bool {
2044 url.contains("://") && !url.starts_with("file://")
2045 || url
2046 .split_once(':')
2047 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2048}
2049
2050fn forge_unavailable(message: &str) -> bool {
2053 message.contains("known GitHub host") || message.contains("spawn gh")
2054}
2055
2056pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2059 let how = match evidence.proof {
2060 crate::already::Proof::PatchId => format!(
2061 "carried by commit {} on `{base}` with the same patch",
2062 evidence.names()
2063 ),
2064 crate::already::Proof::Ancestry => {
2065 format!("already in the history of `{base}` as {}", evidence.names())
2066 }
2067 crate::already::Proof::Tree => format!(
2068 "already part of `{base}` (merging this branch changes nothing at {})",
2069 crate::already::short_sha(&evidence.tip)
2070 ),
2071 };
2072 format!(
2073 "Closing: everything this branch adds is {how}, so there is nothing left to \
2074 land. This pull request was closed automatically after that was verified; \
2075 reopen it if you disagree."
2076 )
2077}
2078
2079pub async fn close_superseded_pr(
2091 state: &mut RunState,
2092 branch: &str,
2093 evidence: &crate::already::Evidence,
2094 verified: &[String],
2095) -> Result<std::result::Result<String, String>> {
2096 let repo = state.repo.clone();
2097 let base = state.base_branch.clone();
2098 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2099 Some(p) => p.url.clone(),
2100 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2105 return Ok(Err(
2106 "the remote is not a forge, so there is no pull request".to_owned(),
2107 ));
2108 }
2109 None => match find_open_pr(&repo, branch, &base).await {
2110 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2111 return Ok(Err(format!("no forge to ask: {e:#}")));
2112 }
2113 Err(e) => return Err(e),
2114 Ok(OpenPr::One { url, .. }) => url,
2115 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2116 Ok(OpenPr::Many(urls)) => {
2117 return Ok(Err(format!(
2118 "{} open pull requests name it; not choosing between them",
2119 urls.len()
2120 )));
2121 }
2122 },
2123 };
2124 let (ok, view) = gh(
2125 &repo,
2126 &[
2127 "pr".to_owned(),
2128 "view".to_owned(),
2129 url.clone(),
2130 "--json".to_owned(),
2131 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2132 ],
2133 )
2134 .await?;
2135 if !ok {
2136 bail!("gh pr view {url} failed: {view}");
2137 }
2138 if let Err(refusal) = closable(&view, branch, &base, verified) {
2139 if refusal.retry {
2142 bail!("left {url} open: {}", refusal.why);
2143 }
2144 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2145 }
2146 let (ok, out) = gh(
2147 &repo,
2148 &[
2149 "pr".to_owned(),
2150 "close".to_owned(),
2151 url.clone(),
2152 "--comment".to_owned(),
2153 superseded_comment(&base, evidence),
2154 ],
2155 )
2156 .await?;
2157 if !ok {
2158 bail!("gh pr close {url} failed: {out}");
2159 }
2160 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2161 p.state = "closed".to_owned();
2162 }
2163 Ok(Ok(url))
2164}
2165
2166pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2170 let (ok, out) = gh(
2171 repo,
2172 &[
2173 "pr".to_owned(),
2174 "edit".to_owned(),
2175 url.to_owned(),
2176 "--title".to_owned(),
2177 title.to_owned(),
2178 ],
2179 )
2180 .await?;
2181 if !ok {
2182 bail!("gh pr edit failed: {out}");
2183 }
2184 Ok(())
2185}
2186
2187pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2200 let Some(winner) = state.winner() else {
2201 return Ok(None);
2202 };
2203 let branch = winner.branch.clone();
2204 let out = gh(
2205 &state.repo,
2206 &[
2207 "pr".to_owned(),
2208 "list".to_owned(),
2209 "--head".to_owned(),
2210 branch.clone(),
2211 "--state".to_owned(),
2212 "merged".to_owned(),
2213 "--json".to_owned(),
2214 "url,number,mergedAt,baseRefName".to_owned(),
2215 ],
2216 )
2217 .await?;
2218 if !out.0 {
2219 bail!("gh pr list --head {branch}: {}", out.1);
2220 }
2221 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2222}
2223
2224pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2236 let out = tokio::process::Command::new("git")
2237 .args(["merge-base", "--is-ancestor", branch, base_branch])
2238 .current_dir(repo)
2239 .quiet()
2240 .stdin(std::process::Stdio::null())
2241 .output()
2242 .await
2243 .context("spawn git merge-base --is-ancestor")?;
2244 Ok(out.status.success())
2245}
2246
2247fn forge_slug(url: &str) -> Option<(String, &str)> {
2255 let rest = url.rsplit("://").next()?;
2256 let (host, path) = rest.split_once('/')?;
2257 if host.is_empty() {
2258 return None;
2259 }
2260 Some((host.to_ascii_lowercase(), path))
2261}
2262
2263pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2271 let (host, path) = forge_slug(url)?;
2272 let mut segments = path.split('/');
2273 let owner = segments.next()?;
2274 let repo = segments.next()?;
2275 let kind = segments.next()?;
2276 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2277 return None;
2278 }
2279 Some(format!("{host}/{owner}/{repo}"))
2280}
2281
2282fn slug_of_repo_url(url: &str) -> Option<String> {
2286 let (host, path) = forge_slug(url)?;
2287 let mut segments = path.split('/');
2288 let owner = segments.next()?;
2289 let repo = segments.next()?;
2290 if owner.is_empty() || repo.is_empty() {
2291 return None;
2292 }
2293 Some(format!("{host}/{owner}/{repo}"))
2294}
2295
2296pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2317 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2318 return Ok(());
2319 }
2320 bail!(
2321 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2322 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2323 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2324 verify by hand before treating this as a false positive)"
2325 );
2326}
2327
2328async fn repo_slug(repo: &Path) -> Result<String> {
2339 let out = gh(
2340 repo,
2341 &[
2342 "repo".to_owned(),
2343 "view".to_owned(),
2344 "--json".to_owned(),
2345 "url".to_owned(),
2346 ],
2347 )
2348 .await?;
2349 if !out.0 {
2350 bail!("gh repo view --json url: {}", out.1);
2351 }
2352 #[derive(Debug, Deserialize)]
2353 struct GhRepo {
2354 url: String,
2355 }
2356 let parsed: GhRepo = serde_json::from_str(&out.1)
2357 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2358 slug_of_repo_url(&parsed.url)
2359 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2360}
2361
2362pub async fn correct_manual_merge(
2410 state: &mut RunState,
2411 url: &str,
2412) -> Result<(RunStatus, RunStatus)> {
2413 let Some(pr_slug) = slug_of_pr_url(url) else {
2414 bail!(
2415 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2416 this pull request belongs to"
2417 );
2418 };
2419 let run_slug = repo_slug(&state.repo).await?;
2420 ensure_same_repo(&run_slug, &pr_slug)?;
2421 correct_merge(state, url).await
2422}
2423
2424pub(crate) async fn correct_confirmed_external_merge(
2435 state: &mut RunState,
2436 url: &str,
2437) -> Result<(RunStatus, RunStatus)> {
2438 correct_merge(state, url).await
2439}
2440
2441fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2443 let Some(pr) = a.pr.as_ref() else {
2444 return false;
2445 };
2446 if !url.is_empty()
2447 && pr
2448 .url
2449 .trim_end_matches('/')
2450 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2451 {
2452 return true;
2453 }
2454 number > 0
2455 && pr.number == number
2456 && match (a.repo.canonicalize(), repo.canonicalize()) {
2457 (Ok(x), Ok(y)) => x == y,
2458 _ => a.repo == repo,
2459 }
2460}
2461
2462fn rewrite_open_prs(
2475 home: &Path,
2476 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2477) -> usize {
2478 let now = Timestamp::now();
2479 let mut changed = 0;
2480 for id in crate::run::list_ids_in(&home.join("runs")) {
2481 let path = home.join("runs").join(&id).join("run.json");
2482 let Ok(body) = std::fs::read_to_string(&path) else {
2483 continue;
2484 };
2485 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2486 continue;
2487 };
2488 if !state.status.done()
2489 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2490 || crate::daemon::is_working_on(home, &id, now)
2491 {
2492 continue;
2493 }
2494 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2495 continue;
2496 };
2497 if let Some(pr) = state.pr.as_mut() {
2498 pr.state = to.as_str().to_owned();
2499 }
2500 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2501 state.event(
2502 "land",
2503 format!("recorded {url} as {}: another run settled it", to.as_str()),
2504 );
2505 match state.save_under(home) {
2506 Ok(()) => changed += 1,
2507 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2508 }
2509 }
2510 changed
2511}
2512
2513pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2518 if to == PrLifecycle::Open {
2519 return;
2520 }
2521 let Some(home) = crate::run::try_home() else {
2522 return;
2523 };
2524 write_pr_state_through_in(&home, run, to);
2525}
2526
2527pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2528 let Some(pr) = run.pr.as_ref() else {
2529 return 0;
2530 };
2531 let (url, number) = (pr.url.clone(), pr.number);
2532 rewrite_open_prs(home, &mut |other| {
2533 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2534 })
2535}
2536
2537pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2540 let now = Timestamp::now();
2541 let mut out = Vec::new();
2542 for id in crate::run::list_ids_in(&home.join("runs")) {
2543 let path = home.join("runs").join(&id).join("run.json");
2544 let Ok(body) = std::fs::read_to_string(&path) else {
2545 continue;
2546 };
2547 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2548 continue;
2549 };
2550 if let Some(pr) = state.pr.as_ref()
2551 && state.status.done()
2552 && pr.state == "open"
2553 && !pr.url.is_empty()
2554 && !crate::daemon::is_working_on(home, &id, now)
2555 {
2556 out.push((id, state.repo.clone(), pr.url.clone()));
2557 }
2558 }
2559 out
2560}
2561
2562pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2565 rewrite_open_prs(home, &mut |s| {
2566 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2567 })
2568}
2569
2570pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2577 let mut known = BTreeMap::new();
2578 let mut failed = 0;
2579 let mut seen = BTreeSet::new();
2580 for (_, repo, url) in stale_open_prs(home) {
2581 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2582 continue;
2583 }
2584 match lifecycle(&repo, &url).await {
2585 Ok(state) => {
2586 known.insert(url, state);
2587 }
2588 Err(e) => {
2589 tracing::warn!("repair pr state of {url}: {e:#}");
2590 failed += 1;
2591 }
2592 }
2593 }
2594 (apply_pr_states(home, &known), failed)
2595}
2596
2597async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2598 match lifecycle(&state.repo, url).await? {
2599 PrLifecycle::Merged => {}
2600 other => bail!(
2601 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2602 other.as_str(),
2603 state.id
2604 ),
2605 }
2606 let before = state.status;
2607 if let Err(e) = land(state, url).await {
2608 state.status = RunStatus::Blocked;
2615 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2616 state.save()?;
2617 return Err(e).context(format!("confirming the merge of {url}"));
2618 }
2619 state.event(
2620 "fold",
2621 "operator recorded this pull request as a manual merge; this run never \
2622 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2623 bump this change might warrant has to be filed by hand",
2624 );
2625 if state.status == RunStatus::Merged {
2628 crate::followup::after_merge(state, url).await;
2629 }
2630 state.save()?;
2631 Ok((before, state.status))
2632}
2633
2634pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2641 let raw: Vec<GhInline> =
2642 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2643 let mut out = Vec::new();
2644 for c in raw {
2645 push_if_outstanding(
2646 &mut out,
2647 ReviewComment {
2648 author: c.user.login,
2649 path: c.path,
2650 line: c.line,
2651 body: c.body,
2652 },
2653 );
2654 }
2655 Ok(out)
2656}
2657
2658fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2664 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2665 return;
2666 }
2667 if comment.path.is_none() && is_noise(&comment.body) {
2668 return;
2669 }
2670 out.push(comment);
2671}
2672
2673pub fn is_noise(body: &str) -> bool {
2691 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2692 return true;
2693 }
2694 let mut content = false;
2695 for line in strip_blocks(body).lines() {
2696 let line = unquote(line);
2697 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2698 continue;
2699 }
2700 content = true;
2701 break;
2702 }
2703 !content
2704}
2705
2706fn strip_blocks(body: &str) -> String {
2708 let mut out = String::with_capacity(body.len());
2709 let mut rest = body;
2710 loop {
2711 let open = ["<!--", "<details>"]
2712 .iter()
2713 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2714 .min_by_key(|(i, _)| *i);
2715 let Some((at, tag)) = open else {
2716 out.push_str(rest);
2717 return out;
2718 };
2719 out.push_str(&rest[..at]);
2720 let after = &rest[at + tag.len()..];
2721 let close = if tag == "<!--" { "-->" } else { "</details>" };
2722 match after.find(close) {
2723 Some(end) => rest = &after[end + close.len()..],
2724 None => return out,
2726 }
2727 }
2728}
2729
2730fn unquote(line: &str) -> &str {
2732 let mut s = line.trim();
2733 while let Some(rest) = s.strip_prefix('>') {
2734 s = rest.trim_start();
2735 }
2736 s.trim()
2737}
2738
2739fn is_checklist(line: &str) -> bool {
2741 let rest = line
2742 .strip_prefix("- ")
2743 .or_else(|| line.strip_prefix("* "))
2744 .unwrap_or("");
2745 let rest = rest.trim_start();
2746 matches!(
2747 rest.get(..3),
2748 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2749 )
2750}
2751
2752fn is_decoration(line: &str) -> bool {
2754 line.starts_with('#')
2755 || line.starts_with("[!")
2756 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2757}
2758
2759fn is_banner(line: &str) -> bool {
2766 let plain = drop_spans(line, "**", "**");
2767 let plain = if plain.contains("](") {
2768 drop_spans(&plain, "[", ")")
2769 } else {
2770 plain
2771 };
2772 !plain.chars().any(char::is_alphanumeric)
2773}
2774
2775fn drop_spans(s: &str, open: &str, close: &str) -> String {
2779 let mut out = String::with_capacity(s.len());
2780 let mut rest = s;
2781 while let Some(at) = rest.find(open) {
2782 out.push_str(&rest[..at]);
2783 let after = &rest[at + open.len()..];
2784 match after.find(close) {
2785 Some(end) => rest = &after[end + close.len()..],
2786 None => return out,
2787 }
2788 }
2789 out.push_str(rest);
2790 out
2791}
2792
2793fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2811 static LOCKS: std::sync::LazyLock<
2812 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2813 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2814 LOCKS
2815 .lock()
2816 .unwrap_or_else(std::sync::PoisonError::into_inner)
2817 .entry(repo.to_path_buf())
2818 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2819 .clone()
2820}
2821
2822fn repo_label(repo: &Path, pr_url: &str) -> String {
2825 let parts: Vec<&str> = pr_url.split('/').collect();
2826 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2827 && at >= 2
2828 && !parts[at - 1].is_empty()
2829 && !parts[at - 2].is_empty()
2830 {
2831 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2832 }
2833 repo.file_name()
2834 .map(|n| n.to_string_lossy().into_owned())
2835 .unwrap_or_default()
2836}
2837
2838fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2842 (pr.checks == Checks::Red).then(|| {
2843 format!(
2844 "Merged {repo_name} PR #{} with red checks: {} ({})",
2845 pr.number,
2846 if pr.failing.is_empty() {
2847 "(none named)".to_owned()
2848 } else {
2849 pr.failing.join(", ")
2850 },
2851 pr.url
2852 )
2853 })
2854}
2855
2856async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2860 let repo_name = repo_label(&state.repo, &pr.url);
2861 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2862 return;
2863 };
2864 if let Some(rec) = state.pr.as_mut() {
2865 rec.red_at_merge = pr.failing.clone();
2866 }
2867 state.event("land", summary.clone());
2868 crate::notices::raise_with(
2871 crate::notices::merged_red(&state.id, &summary),
2872 &state.config.notify,
2873 );
2874}
2875
2876pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2883 land_with(state, pr_url, &GhForge).await
2884}
2885
2886trait Forge {
2890 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2891 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2892 async fn poll(&self);
2893 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2896 #[allow(clippy::too_many_arguments)]
2897 async fn fix(
2898 &self,
2899 state: &mut RunState,
2900 pr: &PrState,
2901 round: usize,
2902 budget: usize,
2903 reason: &str,
2904 logs: &str,
2905 ) -> Result<Fixed>;
2906}
2907
2908struct GhForge;
2909
2910impl Forge for GhForge {
2911 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2912 observe(repo, pr_url).await
2913 }
2914 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2915 gh(repo, argv).await
2916 }
2917 async fn poll(&self) {
2918 tokio::time::sleep(POLL).await;
2919 }
2920 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2921 required_contexts_of(repo, base).await
2922 }
2923 async fn fix(
2924 &self,
2925 state: &mut RunState,
2926 pr: &PrState,
2927 round: usize,
2928 budget: usize,
2929 reason: &str,
2930 logs: &str,
2931 ) -> Result<Fixed> {
2932 fix_round(state, pr, round, budget, reason, logs).await
2933 }
2934}
2935
2936fn encode_path_segment(s: &str) -> String {
2938 let mut out = String::new();
2939 for b in s.bytes() {
2940 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2941 out.push(b as char);
2942 } else {
2943 let _ = write!(out, "%{b:02X}");
2944 }
2945 }
2946 out
2947}
2948
2949async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2954 let enc = encode_path_segment(base);
2955 let mut all = BTreeSet::new();
2956 let classic = gh(
2959 repo,
2960 &[
2961 "api".to_owned(),
2962 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2963 ],
2964 )
2965 .await
2966 .ok()?;
2967 if classic.0 {
2968 all.extend(parse_classic_required(&classic.1)?);
2969 } else if !classic.1.contains("404") {
2970 return None;
2971 }
2972 let rules = gh(
2973 repo,
2974 &[
2975 "api".to_owned(),
2976 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2977 ],
2978 )
2979 .await
2980 .ok()?;
2981 if !rules.0 {
2982 return None;
2983 }
2984 all.extend(parse_ruleset_required(&rules.1)?);
2985 Some(all)
2986}
2987
2988fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2991 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2992 let mut out = BTreeSet::new();
2993 for c in v.get("contexts")?.as_array()? {
2994 out.insert(c.as_str()?.to_owned());
2995 }
2996 for c in v
2997 .get("checks")
2998 .and_then(|c| c.as_array())
2999 .into_iter()
3000 .flatten()
3001 {
3002 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3003 out.insert(name.to_owned());
3004 }
3005 }
3006 Some(out)
3007}
3008
3009fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3011 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3012 let mut out = BTreeSet::new();
3013 for rule in v.as_array()? {
3014 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3015 continue;
3016 }
3017 let checks = rule
3018 .pointer("/parameters/required_status_checks")?
3019 .as_array()?;
3020 for c in checks {
3021 out.insert(c.get("context")?.as_str()?.to_owned());
3022 }
3023 }
3024 Some(out)
3025}
3026
3027fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3035 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3036}
3037
3038fn bound_head<'a>(
3048 seen_head: &'a str,
3049 rollup_head: &str,
3050 awaiting: Option<&str>,
3051) -> Option<&'a str> {
3052 if seen_head.is_empty()
3053 || awaiting_new_head(awaiting, seen_head)
3054 || !rollup_head.eq_ignore_ascii_case(seen_head)
3055 {
3056 return None;
3057 }
3058 Some(seen_head)
3059}
3060
3061#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3063enum Refused {
3064 Pending,
3066 Recheck,
3070 Final,
3073}
3074
3075fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3078 let Some(after) = after else {
3079 return Refused::Pending;
3081 };
3082 if after.pr.state != PrLifecycle::Open {
3083 return Refused::Final;
3084 }
3085 if !after.head.eq_ignore_ascii_case(observed_head) {
3088 return Refused::Pending;
3089 }
3090 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3092 return Refused::Pending;
3093 }
3094 let state = after.merge_state.to_ascii_uppercase();
3095 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3096 || state.is_empty()
3097 || state == "UNKNOWN"
3098 {
3099 return Refused::Pending;
3100 }
3101 if rechecked {
3102 Refused::Final
3103 } else {
3104 Refused::Recheck
3105 }
3106}
3107
3108async fn disarm<F: Forge>(
3113 forge: &F,
3114 state: &mut RunState,
3115 repo: &Path,
3116 number: u64,
3117) -> std::result::Result<(), String> {
3118 let argv = disable_automerge_argv(number);
3119 let out = {
3120 let merge_lock = repo_merge_lock(repo);
3121 let _merge_slot = merge_lock.lock().await;
3122 forge.merge(repo, &argv).await
3123 };
3124 match out {
3125 Ok((true, _)) => {
3126 state.land_armed_head = None;
3127 state.event("land", "auto-merge disabled");
3128 state.save().map_err(|e| format!("{e:#}"))?;
3129 Ok(())
3130 }
3131 Ok((false, msg)) => Err(msg),
3132 Err(e) => Err(format!("{e:#}")),
3133 }
3134}
3135
3136async fn stop_disarmed<F: Forge>(
3140 forge: &F,
3141 state: &mut RunState,
3142 repo: &Path,
3143 pr: &PrState,
3144 why: &str,
3145) -> Result<()> {
3146 if state.land_armed_head.is_none() {
3147 return stop(state, repo, pr, why).await;
3148 }
3149 match disarm(forge, state, repo, pr.number).await {
3150 Ok(()) => stop(state, repo, pr, why).await,
3151 Err(e) => {
3152 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3153 stop(state, repo, pr, &why).await
3154 }
3155 }
3156}
3157
3158async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3159 let repo = state.repo.clone();
3160 let budget = state.config.graph.land_rounds;
3161 let mut round = 0usize;
3162 let mut rebases = 0usize;
3165 let mut waited = Duration::ZERO;
3166 let mut shown: BTreeSet<String> = BTreeSet::new();
3171 let mut awaiting_head: Option<String> = None;
3175 let mut rechecked = false;
3177
3178 state.status = RunStatus::Landing;
3186 state.event("land", format!("watching {pr_url}"));
3187 state.save()?;
3188
3189 let mut resumed_armed = state.land_armed_head.is_some();
3193
3194 loop {
3195 let seen = forge.view(&repo, pr_url).await?;
3196 let mut pr = seen.pr.clone();
3197 pr.review_comments.retain(|c| !shown.contains(&c.body));
3198 state.pr = Some(crate::run::PrRecord {
3199 url: pr.url.clone(),
3200 number: pr.number,
3201 state: pr.state.as_str().to_owned(),
3202 checks: pr.checks.as_str().to_owned(),
3203 round,
3204 rounds: budget,
3205 red_at_merge: Vec::new(),
3206 });
3207 state.save()?;
3208
3209 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3210 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3215 let why = format!(
3216 "a previous pass may have armed auto-merge and it could not be disabled \
3217 on resume: {e}"
3218 );
3219 stop(state, &repo, &pr, &why).await?;
3220 return Ok(pr);
3221 }
3222 }
3223
3224 if pr.state == PrLifecycle::Open
3229 && !seen.head.is_empty()
3230 && state
3231 .land_armed_head
3232 .as_deref()
3233 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3234 {
3235 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3236 let why = format!(
3237 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3238 );
3239 stop(state, &repo, &pr, &why).await?;
3240 return Ok(pr);
3241 }
3242 }
3243
3244 if pr.state == PrLifecycle::Open {
3245 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3246 if waited >= WAIT_CEILING {
3247 let want = awaiting_head.as_deref().unwrap_or_default();
3248 let why = format!(
3249 "the pull request's checks were still not about one readable head after \
3250 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3251 someone may have pushed over it",
3252 WAIT_CEILING.as_secs() / 60,
3253 if want.is_empty() { "any" } else { want },
3254 if seen.head.is_empty() {
3255 "nothing readable"
3256 } else {
3257 &seen.head
3258 },
3259 if seen.rollup_head.is_empty() {
3260 "nothing readable"
3261 } else {
3262 &seen.rollup_head
3263 },
3264 );
3265 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3266 return Ok(pr);
3267 }
3268 waited += POLL;
3269 forge.poll().await;
3270 continue;
3271 }
3272 if awaiting_head.take().is_some() {
3275 waited = Duration::ZERO;
3278 }
3279 }
3280
3281 let step = decide(&pr, round, budget, waited);
3282 let armed_here = state
3287 .land_armed_head
3288 .as_deref()
3289 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3290 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3291 if waited >= WAIT_CEILING {
3292 let required = if seen.base.is_empty() {
3293 None
3294 } else {
3295 forge.required_contexts(&repo, &seen.base).await
3296 };
3297 let why = format!(
3298 "auto-merge was armed on {} but the pull request did not merge within {} \
3299 minutes ({})",
3300 seen.head,
3301 WAIT_CEILING.as_secs() / 60,
3302 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3303 );
3304 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3305 return Ok(pr);
3306 }
3307 waited += POLL;
3308 forge.poll().await;
3309 continue;
3310 }
3311 if armed_here && !matches!(step, Step::Done { .. }) {
3312 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3315 let why = format!("auto-merge could not be disabled: {e}");
3316 stop(state, &repo, &pr, &why).await?;
3317 return Ok(pr);
3318 }
3319 }
3320 match step {
3321 Step::Wait => {
3322 if waited >= WAIT_CEILING {
3323 let why = format!(
3324 "checks were still running after {} minutes",
3325 WAIT_CEILING.as_secs() / 60
3326 );
3327 stop(state, &repo, &pr, &why).await?;
3328 return Ok(pr);
3329 }
3330 waited += POLL;
3331 forge.poll().await;
3332 }
3333 Step::Done { merged } => {
3334 if let Some(armed) = state.land_armed_head.take() {
3339 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3340 let msg = format!(
3341 "{} merged on {} but the owner approved {armed}; review what landed",
3342 pr.url, seen.head
3343 );
3344 tracing::warn!("{msg}");
3345 state.event("land", msg);
3346 crate::notices::raise_with(
3350 crate::notices::Notice::warn(
3351 &format!("merged-unapproved-head:{}", state.id),
3352 "A pull request merged on a commit the owner did not approve; \
3353 review what landed",
3354 )
3355 .link(crate::notices::Link::Run {
3356 id: state.id.clone(),
3357 }),
3358 &state.config.notify,
3359 );
3360 }
3361 }
3362 state.status = if merged {
3363 RunStatus::Merged
3364 } else {
3365 RunStatus::Ready
3366 };
3367 let detail = if merged {
3368 format!("{} was merged", pr.url)
3369 } else {
3370 format!("{} was closed without merging", pr.url)
3371 };
3372 state.merge = Some(MergeOutcome {
3373 mode: MergeMode::Pr,
3374 ok: merged,
3375 detail: detail.clone(),
3376 empty: false,
3377 });
3378 state.event("land", detail);
3379 state.save()?;
3380 write_pr_state_through(state, pr.state);
3381 return Ok(pr);
3382 }
3383 Step::Merge => {
3384 let subject = merge_subject(
3385 crate::graph::landing_title(state, &seen.title),
3386 &crate::graph::landing_subject_source(state),
3387 );
3388 let contested = contested_to_ask(state);
3395 if state.config.graph.land_approval || contested.is_some() {
3396 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3397 .await?
3398 {
3399 ApprovalGate::Approved => {}
3400 ApprovalGate::Held => {
3401 stop(
3402 state,
3403 &repo,
3404 &pr,
3405 "the owner did not approve the merge (held or unanswered)",
3406 )
3407 .await?;
3408 return Ok(pr);
3409 }
3410 ApprovalGate::Pending => {
3418 state.parked = true;
3419 state.event(
3420 "land",
3421 "parked awaiting merge approval - resumes once answered",
3422 );
3423 state.save()?;
3424 return Ok(pr);
3425 }
3426 }
3427 }
3428 let observed_head = seen.head.clone();
3439 {
3442 let fresh = forge.view(&repo, pr_url).await.ok();
3443 if !direct_merge_is_safe(
3444 fresh.as_ref(),
3445 &observed_head,
3446 &shown,
3447 round,
3448 budget,
3449 waited,
3450 ) {
3451 if waited >= WAIT_CEILING {
3452 let why = "the pull request did not settle on the approved head \
3453 before it could be merged";
3454 stop(state, &repo, &pr, why).await?;
3455 return Ok(pr);
3456 }
3457 state.event(
3458 "land",
3459 "the pull request changed before merging; looking again",
3460 );
3461 waited += POLL;
3462 forge.poll().await;
3463 continue;
3464 }
3465 }
3466 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3467 let out = {
3468 let merge_lock = repo_merge_lock(&repo);
3469 let _merge_slot = merge_lock.lock().await;
3470 forge.merge(&repo, &argv).await?
3471 };
3472 if out.0 {
3473 let confirmed = forge
3479 .view(&repo, pr_url)
3480 .await
3481 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3482 if !confirmed {
3483 if waited >= WAIT_CEILING {
3484 let why = "the merge request succeeded but the pull request \
3485 could not be confirmed merged after waiting";
3486 stop(state, &repo, &pr, why).await?;
3487 return Ok(pr);
3488 }
3489 state.event(
3490 "land",
3491 "merge accepted but the pull request is not confirmed merged yet; waiting",
3492 );
3493 state.save()?;
3494 waited += POLL;
3495 forge.poll().await;
3496 continue;
3497 }
3498 pr.state = PrLifecycle::Merged;
3499 state.status = RunStatus::Merged;
3500 state.merge = Some(MergeOutcome {
3501 mode: MergeMode::Pr,
3502 ok: true,
3503 detail: format!("gh {}", argv.join(" ")),
3504 empty: false,
3505 });
3506 if let Some(pr_record) = state.pr.as_mut() {
3511 pr_record.state = pr.state.as_str().to_owned();
3512 }
3513 state.event("land", format!("merged {} as `{subject}`", pr.url));
3514 announce_red_merge(state, &pr).await;
3515 state.save()?;
3516 write_pr_state_through(state, pr.state);
3517 return Ok(pr);
3518 }
3519 let after_seen = forge.view(&repo, pr_url).await.ok();
3520 let after = after_seen.as_ref().map(|s| s.pr.state);
3521 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3522 pr.state = PrLifecycle::Merged;
3523 state.status = RunStatus::Merged;
3524 state.merge = Some(outcome);
3525 if let Some(pr_record) = state.pr.as_mut() {
3526 pr_record.state = pr.state.as_str().to_owned();
3527 }
3528 state.event("land", format!("merged {} as `{subject}`", pr.url));
3529 announce_red_merge(state, &pr).await;
3530 state.save()?;
3531 write_pr_state_through(state, pr.state);
3532 return Ok(pr);
3533 }
3534 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3535 match verdict {
3536 Refused::Final => {
3537 let merge_state = after_seen
3538 .as_ref()
3539 .map(|s| s.merge_state.as_str())
3540 .filter(|m| !m.is_empty())
3541 .unwrap_or("unknown");
3542 let why = format!(
3546 "the merge was refused: {} (merge state: {merge_state})",
3547 out.1
3548 );
3549 stop(state, &repo, &pr, &why).await?;
3550 return Ok(pr);
3551 }
3552 verdict => {
3553 if waited >= WAIT_CEILING {
3558 let why = format!(
3559 "the merge was still refused after {} minutes: {}",
3560 WAIT_CEILING.as_secs() / 60,
3561 out.1
3562 );
3563 stop(state, &repo, &pr, &why).await?;
3564 return Ok(pr);
3565 }
3566 if verdict == Refused::Recheck {
3567 rechecked = true;
3568 }
3569 state.event(
3570 "land",
3571 "merge refused while the branch policy is not satisfied yet; waiting",
3572 );
3573 state.save()?;
3574 waited += POLL;
3575 forge.poll().await;
3576 }
3577 }
3578 }
3579 Step::Rebase => {
3580 if rebases >= budget {
3586 let why = format!(
3587 "the base moved under this branch {budget} time(s) and it still does \
3588 not merge; rebasing again would only race it"
3589 );
3590 stop(state, &repo, &pr, &why).await?;
3591 return Ok(pr);
3592 }
3593 rebases += 1;
3594 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3595 stop(
3596 state,
3597 &repo,
3598 &pr,
3599 "the pull request conflicts and this run has no winning branch to rebase",
3600 )
3601 .await?;
3602 return Ok(pr);
3603 };
3604 let base = state.base_branch.clone();
3605 state.event(
3606 "land",
3607 format!("{} no longer merges; rebasing onto {base}", pr.url),
3608 );
3609 state.save()?;
3610
3611 git::fetch(&repo, "origin", &base).await.ok();
3615 let scratch = state.dir().join("rebase");
3616 let onto = format!("origin/{base}");
3617 let rebased =
3618 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3619 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3620 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3621 Err(e) => Err(e),
3622 };
3623 match rebased {
3624 Ok(None) => {
3625 let pushed = {
3626 let merge_lock = repo_merge_lock(&repo);
3627 let _merge_slot = merge_lock.lock().await;
3628 git::push_rewritten(&repo, "origin", &branch).await?
3629 };
3630 if !pushed.ok() {
3631 let why = format!(
3632 "rebased {branch} but could not push it: {}",
3633 pushed.stderr.trim()
3634 );
3635 stop(state, &repo, &pr, &why).await?;
3636 return Ok(pr);
3637 }
3638 let head =
3641 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3642 Ok(head) => head,
3643 Err(e) => {
3644 let why = format!(
3645 "rebased and pushed {branch} but could not read the pushed \
3646 commit: {e:#}"
3647 );
3648 stop(state, &repo, &pr, &why).await?;
3649 return Ok(pr);
3650 }
3651 };
3652 crate::graph::refresh_reviewed_commits(state, &branch).await;
3653 awaiting_head = Some(head);
3654 rechecked = false;
3655 state.event("land", format!("rebased {branch} onto {base}"));
3656 state.save()?;
3657 waited = Duration::ZERO;
3660 tokio::time::sleep(POLL).await;
3661 }
3662 Ok(Some(conflict)) => {
3665 let why = format!(
3666 "{} conflicts with {base} and the rebase did not apply: {}",
3667 pr.url,
3668 conflict.chars().take(600).collect::<String>()
3669 );
3670 stop(state, &repo, &pr, &why).await?;
3671 return Ok(pr);
3672 }
3673 Err(e) => {
3674 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3675 stop(state, &repo, &pr, &why).await?;
3676 return Ok(pr);
3677 }
3678 }
3679 }
3680 Step::GiveUp { reason } => {
3681 stop(state, &repo, &pr, &reason).await?;
3682 return Ok(pr);
3683 }
3684 Step::Fix { reason } => {
3685 round += 1;
3686 waited = Duration::ZERO;
3687 for c in &pr.review_comments {
3688 shown.insert(c.body.clone());
3689 }
3690 state.event("land", format!("round {round}: {reason}"));
3691 state.save()?;
3692
3693 let logs = failing_logs(&repo, &seen.failing_urls).await;
3694 let was_red = pr.checks == Checks::Red;
3695 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3696 Fixed::Committed { head } => {
3697 awaiting_head = Some(head);
3700 rechecked = false;
3701 waited = Duration::ZERO;
3702 forge.poll().await;
3703 }
3704 Fixed::Declined if was_red => {
3705 let why = format!(
3706 "the fixer produced no commit while {} check(s) were failing \
3707 ({}); stopping instead of looping on an unchanged tree",
3708 pr.failing.len(),
3709 pr.failing.join(", ")
3710 );
3711 stop(state, &repo, &pr, &why).await?;
3712 return Ok(pr);
3713 }
3714 Fixed::Declined => state.event(
3719 "land",
3720 format!("round {round}: fixer declined the comments, nothing committed"),
3721 ),
3722 Fixed::Failed(why) => {
3723 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3724 return Ok(pr);
3725 }
3726 }
3727 state.save()?;
3728 }
3729 }
3730 }
3731}
3732
3733#[derive(Clone)]
3737struct Seen {
3738 pr: PrState,
3739 title: String,
3740 failing_urls: Vec<(String, String)>,
3741 head: String,
3743 rollup_head: String,
3746 merge_state: String,
3749 contexts: Vec<CheckInfo>,
3751 base: String,
3753}
3754
3755#[derive(Clone)]
3757struct CheckInfo {
3758 label: String,
3759 verdict: Verdict,
3760 required: Option<bool>,
3761}
3762
3763async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3767 let view = gh(
3768 repo,
3769 &[
3770 "pr".to_owned(),
3771 "view".to_owned(),
3772 pr_url.to_owned(),
3773 "--json".to_owned(),
3774 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3775 .to_owned(),
3776 ],
3777 )
3778 .await?;
3779 if !view.0 {
3780 bail!("gh pr view {pr_url}: {}", view.1);
3781 }
3782 let number = parse_pr(&view.1)?.number;
3783 let node = last_commit_node(repo, number).await;
3784 let mut seen = seen_from(&view.1, node.as_deref())?;
3785
3786 let inline = gh(
3787 repo,
3788 &[
3789 "api".to_owned(),
3790 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3791 ],
3792 )
3793 .await?;
3794 if inline.0 {
3795 match parse_inline_comments(&inline.1) {
3796 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3797 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3800 }
3801 } else {
3802 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3803 }
3804 Ok(seen)
3805}
3806
3807fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3818 let mut pr = parse_pr(view_json)?;
3819 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3820
3821 let mut rollup_head = String::new();
3822 let mut failing_urls = Vec::new();
3823 let mut contexts = Vec::new();
3824 let mut checks = Checks::Unknown;
3825 let mut failing = Vec::new();
3826 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3827 (checks, failing) = rollup_verdict(&rollup);
3828 failing_urls = rollup
3829 .iter()
3830 .filter(|c| c.verdict() == Verdict::Fail)
3831 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3832 .collect();
3833 contexts = rollup
3834 .iter()
3835 .map(|c| CheckInfo {
3836 label: c.label(),
3837 verdict: c.verdict(),
3838 required: c.is_required,
3839 })
3840 .collect();
3841 rollup_head = oid;
3842 }
3843 pr.checks = checks;
3844 pr.failing = failing;
3845
3846 Ok(Seen {
3847 pr,
3848 title: raw.title,
3849 failing_urls,
3850 head: raw.head_ref_oid,
3851 rollup_head,
3852 merge_state: raw.merge_state_status,
3853 contexts,
3854 base: raw.base_ref_name,
3855 })
3856}
3857
3858fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3861 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3862 if v.get("errors").is_some_and(|e| !e.is_null()) {
3863 return None;
3864 }
3865 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3866 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3867 let contexts = commit.pointer("/statusCheckRollup/contexts");
3868 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3869 return Some((oid.to_owned(), Vec::new()));
3871 };
3872 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3873 return None;
3874 }
3875 let nodes = contexts.get("nodes")?.as_array()?;
3876 let rollup = nodes
3877 .iter()
3878 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3879 .collect::<Result<Vec<_>, _>>()
3880 .ok()?;
3881 Some((oid.to_owned(), rollup))
3882}
3883
3884async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3887 let out = gh(
3888 repo,
3889 &[
3890 "api".to_owned(),
3891 "graphql".to_owned(),
3892 "-F".to_owned(),
3893 "owner={owner}".to_owned(),
3894 "-F".to_owned(),
3895 "repo={repo}".to_owned(),
3896 "-F".to_owned(),
3897 format!("number={number}"),
3898 "-f".to_owned(),
3899 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3900 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3901 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3902 ... on CheckRun{name status conclusion detailsUrl \
3903 isRequired(pullRequestNumber:$number)} \
3904 ... on StatusContext{context state targetUrl \
3905 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3906 .to_owned(),
3907 ],
3908 )
3909 .await
3910 .ok()?;
3911 out.0.then_some(out.1)
3912}
3913
3914#[doc(hidden)]
3916#[derive(Debug, PartialEq)]
3917pub enum Fixed {
3918 Committed {
3920 head: String,
3922 },
3923 Declined,
3925 Failed(String),
3927}
3928
3929#[doc(hidden)]
3935pub async fn fix_round(
3936 state: &mut RunState,
3937 pr: &PrState,
3938 round: usize,
3939 budget: usize,
3940 reason: &str,
3941 logs: &str,
3942) -> Result<Fixed> {
3943 let winner = state
3944 .winner()
3945 .cloned()
3946 .context("landing needs a winning candidate; none is recorded on this run")?;
3947 let roles = state
3948 .config
3949 .resolve_roles()
3950 .context("resolve the roster for the fix round")?;
3951 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
3955 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3956 _ => (
3957 state
3958 .config
3959 .agent(&winner.agent)
3960 .cloned()
3961 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
3962 format!("impl-{}", winner.label),
3963 ),
3964 };
3965
3966 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3967 let mut seat = seat_of(state, &seat_key, &spec.id);
3968 let artifacts = agent::artifacts_dir(&state.dir());
3969 let prompt = if state.config.cache_dir().is_some() {
3970 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3971 } else {
3972 prompt
3973 };
3974 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3978 let out = agent::invoke(
3979 &spec,
3980 &mut seat,
3981 &Invocation {
3982 cwd: &winner.worktree,
3983 prompt: &prompt,
3984 timeout: Duration::from_secs(state.config.graph.timeout_fix),
3985 allow_write: true,
3986 sessions: state.config.graph.sessions,
3987 artifacts: &artifacts,
3988 stem: &format!("land-{round}"),
3989 run: &state.id,
3990 node: "land",
3991 cache_dir: state.config.cache_dir().as_deref(),
3992 attachments: &[],
3993 writable: &[],
3994 },
3995 )
3996 .await;
3997 state.seats.insert(seat.key.clone(), seat);
3998
3999 match out {
4000 Ok(o) if o.quota_exhausted() => {
4001 return Ok(Fixed::Failed(
4002 "rate limited (quota); the fixer could not run".to_owned(),
4003 ));
4004 }
4005 Ok(o) if !o.usable() => {
4006 return Ok(Fixed::Failed(format!(
4007 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4008 o.exit_code, o.timed_out
4009 )));
4010 }
4011 Ok(_) => {}
4012 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4013 }
4014
4015 if let Ok(r) = git::rescue_commit(
4018 &winner.worktree,
4019 &format!("magi: land round {round} fixes (uncommitted work)"),
4020 )
4021 .await
4022 {
4023 state.note_withheld("land", &r.withheld);
4024 }
4025 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4026 if after == before {
4027 return Ok(Fixed::Declined);
4028 }
4029
4030 let remote = state.config.merge.remote.clone();
4031 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4032 if !push.ok() {
4033 return Ok(Fixed::Failed(format!(
4034 "pushing {} to {remote} failed: {}",
4035 winner.branch, push.stderr
4036 )));
4037 }
4038 state.event(
4039 "land",
4040 format!("round {round}: pushed a fix to {}", winner.branch),
4041 );
4042 Ok(Fixed::Committed { head: after })
4043}
4044
4045pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4047 if let Some(existing) = state.seats.get(key)
4048 && existing.agent == agent
4049 {
4050 return existing.clone();
4051 }
4052 let fresh = SeatState::new(key, agent, state.seed);
4053 state.seats.insert(key.to_owned(), fresh.clone());
4054 fresh
4055}
4056
4057fn fix_prompt(
4059 state: &RunState,
4060 pr: &PrState,
4061 round: usize,
4062 budget: usize,
4063 reason: &str,
4064 logs: &str,
4065) -> String {
4066 let mut s = format!(
4067 "Your patch is open as a pull request and it is not landing. Land round \
4068 {round} of {budget}.\n\n\
4069 Pull request: {}\n\n\
4070 What is holding it: {reason}\n\n\
4071 # The task\n\n{}\n",
4072 pr.url, state.instruction
4073 );
4074
4075 if pr.failing.is_empty() {
4076 s.push_str("\n# Failing checks\n\n(none)\n");
4077 } else {
4078 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4079 if logs.trim().is_empty() {
4080 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4081 } else {
4082 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4083 }
4084 }
4085
4086 if pr.review_comments.is_empty() {
4087 s.push_str("\n# Review comments\n\n(none)\n");
4088 } else {
4089 s.push_str("\n# Review comments\n");
4090 for c in &pr.review_comments {
4091 let where_ = match (&c.path, c.line) {
4092 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4093 (Some(p), None) => format!(" ({p})"),
4094 _ => String::new(),
4095 };
4096 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4097 }
4098 }
4099
4100 s.push_str(
4101 "\n# Rules\n\n\
4102 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4103 failing test; do not silence a lint with an allow attribute; do not \
4104 stretch a timeout to hide a race. If the check is right, the code is \
4105 wrong.\n\
4106 2. Change nothing the checks and the comments did not raise. A \
4107 drive-by refactor turns a one-line fix into a pull request that \
4108 needs reviewing again.\n\
4109 3. If a comment is wrong, say so with a checkable argument and change \
4110 nothing for it. A declined comment with a reason is a correct \
4111 outcome; a change made to appease a reviewer is not.\n\
4112 4. Commit in this worktree. magi pushes to the pull request's branch \
4113 for you; do not push, merge, or close anything yourself.\n\
4114 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4115 # Output\n\n\
4116 Say what you changed and why, and what you declined and why.",
4117 );
4118
4119 let language = &state.config.graph.language;
4120 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4121 let _ = write!(s, "\n\nWrite all prose in {language}.");
4122 }
4123 s.push_str(&crate::prompt::github_english(language));
4125 if let Some(overlay) = state.config.prompts.overlay("fix") {
4126 let _ = write!(s, "\n\n{overlay}");
4127 }
4128 s
4129}
4130
4131async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4134 let mut out = String::new();
4135 for (name, url) in failing.iter().take(MAX_LOGS) {
4136 let args = match (job_of(url), run_of(url)) {
4137 (Some(job), _) => vec![
4138 "run".to_owned(),
4139 "view".to_owned(),
4140 "--log-failed".to_owned(),
4141 "--job".to_owned(),
4142 job,
4143 ],
4144 (None, Some(run)) => vec![
4145 "run".to_owned(),
4146 "view".to_owned(),
4147 run,
4148 "--log-failed".to_owned(),
4149 ],
4150 (None, None) => continue,
4152 };
4153 let (ok, body) = match gh(repo, &args).await {
4154 Ok(v) => v,
4155 Err(e) => (false, format!("{e:#}")),
4156 };
4157 if !ok && body.trim().is_empty() {
4158 continue;
4159 }
4160 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4161 }
4162 out
4163}
4164
4165fn job_of(details_url: &str) -> Option<String> {
4168 let after = details_url.split("/job/").nth(1)?;
4169 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4170 (!id.is_empty()).then_some(id)
4171}
4172
4173pub(crate) fn run_of(details_url: &str) -> Option<String> {
4175 let after = details_url.split("/actions/runs/").nth(1)?;
4176 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4177 (!id.is_empty()).then_some(id)
4178}
4179
4180fn stop_comment(run_id: &str, why: &str) -> String {
4184 format!(
4185 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4186 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4187 )
4188}
4189
4190async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4195 let body = stop_comment(&state.id, why);
4196 let posted = gh(
4197 repo,
4198 &[
4199 "pr".to_owned(),
4200 "comment".to_owned(),
4201 pr.number.to_string(),
4202 "--body".to_owned(),
4203 body,
4204 ],
4205 )
4206 .await;
4207 match posted {
4208 Ok((true, _)) => {}
4209 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4210 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4211 }
4212 state.status = RunStatus::Blocked;
4213 state.merge = Some(MergeOutcome {
4214 mode: MergeMode::Pr,
4215 ok: false,
4216 detail: why.to_owned(),
4217 empty: false,
4218 });
4219 state.event("land", format!("stopped: {why}"));
4220 state.save()?;
4221 Ok(())
4222}
4223
4224pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4240 let out = tokio::process::Command::new("gh")
4241 .args(args)
4242 .current_dir(cwd)
4243 .env_remove("GH_REPO")
4244 .quiet()
4245 .stdin(std::process::Stdio::null())
4246 .output()
4247 .await
4248 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4249 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4250 let err = String::from_utf8_lossy(&out.stderr);
4251 if body.trim().is_empty() {
4252 body = err.into_owned();
4253 } else if !err.trim().is_empty() {
4254 body.push_str(&err);
4255 }
4256 Ok((out.status.success(), body.trim().to_owned()))
4257}
4258
4259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4261pub(crate) enum Verdict {
4262 Pass,
4263 Fail,
4264 Pending,
4265 Unknown,
4266}
4267
4268#[derive(Debug, Deserialize)]
4269#[serde(rename_all = "camelCase")]
4270struct GhPr {
4271 #[serde(default)]
4272 url: String,
4273 #[serde(default)]
4274 number: u64,
4275 #[serde(default)]
4276 state: String,
4277 #[serde(default)]
4278 title: String,
4279 #[serde(default)]
4280 status_check_rollup: Vec<GhCheck>,
4281 #[serde(default)]
4288 merge_state_status: String,
4289 #[serde(default)]
4293 head_ref_oid: String,
4294 #[serde(default)]
4295 base_ref_name: String,
4296 #[serde(default)]
4297 reviews: Vec<GhReview>,
4298 #[serde(default)]
4299 comments: Vec<GhComment>,
4300}
4301
4302#[derive(Debug, Deserialize)]
4307#[serde(rename_all = "camelCase")]
4308struct GhCheck {
4309 #[serde(default)]
4310 name: Option<String>,
4311 #[serde(default)]
4312 context: Option<String>,
4313 #[serde(default)]
4314 status: Option<String>,
4315 #[serde(default)]
4316 conclusion: Option<String>,
4317 #[serde(default)]
4318 state: Option<String>,
4319 #[serde(default)]
4320 details_url: Option<String>,
4321 #[serde(default)]
4322 target_url: Option<String>,
4323 #[serde(default)]
4326 is_required: Option<bool>,
4327}
4328
4329impl GhCheck {
4330 fn label(&self) -> String {
4332 self.name
4333 .clone()
4334 .or_else(|| self.context.clone())
4335 .unwrap_or_else(|| "(unnamed check)".to_owned())
4336 }
4337
4338 fn url(&self) -> Option<&str> {
4340 self.details_url
4341 .as_deref()
4342 .or(self.target_url.as_deref())
4343 .filter(|u| !u.is_empty())
4344 }
4345
4346 fn verdict(&self) -> Verdict {
4354 if let Some(status) = self.status.as_deref() {
4355 if !status.eq_ignore_ascii_case("COMPLETED") {
4356 return Verdict::Pending;
4357 }
4358 }
4359 let outcome = self
4360 .conclusion
4361 .as_deref()
4362 .or(self.state.as_deref())
4363 .unwrap_or("");
4364 match outcome.to_ascii_uppercase().as_str() {
4365 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4366 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4367 | "ACTION_REQUIRED" => Verdict::Fail,
4368 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4369 Verdict::Pending
4370 }
4371 _ => Verdict::Unknown,
4372 }
4373 }
4374}
4375
4376#[derive(Debug, Deserialize)]
4377struct GhAuthor {
4378 #[serde(default)]
4379 login: String,
4380}
4381
4382#[derive(Debug, Deserialize)]
4383struct GhReview {
4384 #[serde(default)]
4385 author: GhAuthor,
4386 #[serde(default)]
4387 body: String,
4388}
4389
4390#[derive(Debug, Deserialize)]
4391struct GhComment {
4392 #[serde(default)]
4393 author: GhAuthor,
4394 #[serde(default)]
4395 body: String,
4396}
4397
4398#[derive(Debug, Deserialize)]
4399struct GhUser {
4400 #[serde(default)]
4401 login: String,
4402}
4403
4404#[derive(Debug, Deserialize)]
4405struct GhInline {
4406 #[serde(default)]
4407 user: GhUser,
4408 #[serde(default)]
4409 path: Option<String>,
4410 #[serde(default)]
4411 line: Option<u64>,
4412 #[serde(default)]
4413 body: String,
4414}
4415
4416impl Default for GhAuthor {
4417 fn default() -> Self {
4418 Self {
4419 login: "(unknown)".to_owned(),
4420 }
4421 }
4422}
4423
4424impl Default for GhUser {
4425 fn default() -> Self {
4426 Self {
4427 login: "(unknown)".to_owned(),
4428 }
4429 }
4430}
4431
4432#[cfg(test)]
4433mod tests {
4434 use super::*;
4435 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4436
4437 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4438 format!(
4439 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4440 )
4441 }
4442
4443 #[test]
4444 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4445 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4446 assert_eq!(
4447 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4448 Ok(())
4449 );
4450 for (json, why) in [
4451 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4452 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4453 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4454 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4455 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4456 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4457 ] {
4458 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4459 .unwrap_err()
4460 .why;
4461 assert!(err.contains(why), "{json}: {err}");
4462 }
4463 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4465 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4466 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4467 assert!(
4468 !closable(
4469 &head_json("x", "main", "OPEN", false),
4470 "magi/27b2/A",
4471 "main",
4472 &[]
4473 )
4474 .unwrap_err()
4475 .retry
4476 );
4477 assert!(is_forge_url("https://github.com/o/r.git"));
4478 assert!(is_forge_url("git@github.com:o/r.git"));
4479 assert!(!is_forge_url("/tmp/origin.git"));
4480 assert!(!is_forge_url("C:\\work\\origin.git"));
4481 assert!(!is_forge_url("file:///tmp/origin.git"));
4482 assert!(forge_unavailable(
4483 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4484 ));
4485 assert!(!forge_unavailable(
4486 "gh pr list failed: error connecting to api.github.com"
4487 ));
4488 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4489 assert!(
4491 closable(
4492 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4493 "b",
4494 "main",
4495 &["aaa".to_owned()]
4496 )
4497 .is_err()
4498 );
4499 }
4500
4501 #[test]
4502 fn the_close_comment_names_the_commit_on_the_base() {
4503 let e = crate::already::Evidence {
4504 proof: crate::already::Proof::PatchId,
4505 tip: "1234567890".to_owned(),
4506 commits: vec!["0e368de0000".to_owned()],
4507 };
4508 let c = superseded_comment("main", &e);
4509 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4510 }
4511
4512 const GREEN_OPEN: &str = r####"{
4514 "url": "https://github.com/yukimemi/magi/pull/10",
4515 "number": 10,
4516 "state": "OPEN",
4517 "mergeStateStatus": "CLEAN",
4518 "statusCheckRollup": [
4519 {
4520 "__typename": "CheckRun",
4521 "conclusion": "SKIPPED",
4522 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4523 "name": "review",
4524 "status": "COMPLETED",
4525 "workflowName": "claude-review"
4526 },
4527 {
4528 "__typename": "CheckRun",
4529 "conclusion": "SUCCESS",
4530 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4531 "name": "check (ubuntu-latest)",
4532 "status": "COMPLETED",
4533 "workflowName": "CI"
4534 },
4535 {
4536 "__typename": "CheckRun",
4537 "conclusion": "SUCCESS",
4538 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4539 "name": "rustfmt",
4540 "status": "COMPLETED",
4541 "workflowName": "CI"
4542 },
4543 {
4544 "__typename": "StatusContext",
4545 "context": "CodeRabbit",
4546 "state": "SUCCESS",
4547 "targetUrl": ""
4548 }
4549 ],
4550 "reviews": [],
4551 "comments": [
4552 {
4553 "author": {
4554 "login": "coderabbitai"
4555 },
4556 "authorAssociation": "NONE",
4557 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro Plus\n> \n> **Run ID**: `78e70bf3-c5a0-4269-a96c-2afb2dba7eff`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=10)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%2"
4558 }
4559 ]
4560}"####;
4561
4562 const RED_OPEN: &str = r####"{
4564 "url": "https://github.com/yukimemi/magi/pull/9",
4565 "number": 9,
4566 "state": "OPEN",
4567 "mergeStateStatus": "UNSTABLE",
4568 "statusCheckRollup": [
4569 {
4570 "__typename": "CheckRun",
4571 "conclusion": "SUCCESS",
4572 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4573 "name": "check (ubuntu-latest)",
4574 "status": "COMPLETED",
4575 "workflowName": "CI"
4576 },
4577 {
4578 "__typename": "CheckRun",
4579 "conclusion": "SUCCESS",
4580 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4581 "name": "rustfmt",
4582 "status": "COMPLETED",
4583 "workflowName": "CI"
4584 },
4585 {
4586 "__typename": "CheckRun",
4587 "conclusion": "FAILURE",
4588 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4589 "name": "editorconfig",
4590 "status": "COMPLETED",
4591 "workflowName": "CI"
4592 },
4593 {
4594 "__typename": "StatusContext",
4595 "context": "CodeRabbit",
4596 "state": "SUCCESS",
4597 "targetUrl": ""
4598 }
4599 ],
4600 "reviews": [],
4601 "comments": [
4602 {
4603 "author": {
4604 "login": "coderabbitai"
4605 },
4606 "authorAssociation": "NONE",
4607 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `91e0dc24-6040-4c3d-92c6-f7d2b542523d`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderab"
4608 }
4609 ]
4610}"####;
4611
4612 const PENDING_OPEN: &str = r####"{
4614 "url": "https://github.com/yukimemi/magi/pull/9",
4615 "number": 9,
4616 "state": "OPEN",
4617 "statusCheckRollup": [
4618 {
4619 "__typename": "CheckRun",
4620 "conclusion": "SUCCESS",
4621 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4622 "name": "check (ubuntu-latest)",
4623 "status": "COMPLETED",
4624 "workflowName": "CI"
4625 },
4626 {
4627 "__typename": "CheckRun",
4628 "conclusion": "SUCCESS",
4629 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4630 "name": "rustfmt",
4631 "status": "COMPLETED",
4632 "workflowName": "CI"
4633 },
4634 {
4635 "__typename": "CheckRun",
4636 "conclusion": null,
4637 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4638 "name": "editorconfig",
4639 "status": "IN_PROGRESS",
4640 "workflowName": "CI"
4641 },
4642 {
4643 "__typename": "StatusContext",
4644 "context": "CodeRabbit",
4645 "state": "SUCCESS",
4646 "targetUrl": ""
4647 }
4648 ],
4649 "reviews": [],
4650 "comments": []
4651}"####;
4652
4653 const MERGED: &str = r####"{
4655 "url": "https://github.com/yukimemi/magi/pull/16",
4656 "number": 16,
4657 "state": "MERGED",
4658 "statusCheckRollup": [
4659 {
4660 "__typename": "CheckRun",
4661 "conclusion": "SUCCESS",
4662 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4663 "name": "check (ubuntu-latest)",
4664 "status": "COMPLETED",
4665 "workflowName": "CI"
4666 },
4667 {
4668 "__typename": "CheckRun",
4669 "conclusion": "SUCCESS",
4670 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4671 "name": "review",
4672 "status": "COMPLETED",
4673 "workflowName": "claude-review"
4674 }
4675 ],
4676 "reviews": [],
4677 "comments": []
4678}"####;
4679
4680 const REVIEWED_OPEN: &str = r####"{
4682 "url": "https://github.com/yukimemi/magi/pull/12",
4683 "number": 12,
4684 "state": "OPEN",
4685 "statusCheckRollup": [
4686 {
4687 "__typename": "CheckRun",
4688 "conclusion": "SUCCESS",
4689 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4690 "name": "check (ubuntu-latest)",
4691 "status": "COMPLETED",
4692 "workflowName": "CI"
4693 },
4694 {
4695 "__typename": "CheckRun",
4696 "conclusion": "SUCCESS",
4697 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4698 "name": "review",
4699 "status": "COMPLETED",
4700 "workflowName": "claude-review"
4701 }
4702 ],
4703 "reviews": [
4704 {
4705 "author": {
4706 "login": "claude"
4707 },
4708 "state": "COMMENTED",
4709 "body": ""
4710 }
4711 ],
4712 "comments": [
4713 {
4714 "author": {
4715 "login": "coderabbitai"
4716 },
4717 "authorAssociation": "NONE",
4718 "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `72058bf3-b7df-41d9-8e4d-a06a31be4a26`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=12)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summa"
4719 },
4720 {
4721 "author": {
4722 "login": "claude"
4723 },
4724 "authorAssociation": "NONE",
4725 "body": "**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)\n\n---\n### Review: `magi review <branch>` — cheap-half-only graph\n\nRead through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.\n\n**Correctness**\n\n- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `\"(existing branch)\"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"
4726 }
4727 ]
4728}"####;
4729
4730 const INLINE: &str = r####"[
4732 {
4733 "user": {
4734 "login": "claude[bot]"
4735 },
4736 "path": "src/graph.rs",
4737 "line": 231,
4738 "body": "Minor edge case: unlike `implement()` (which sets `c.empty = commits == 0 || patch.trim().is_empty()`, `src/graph.rs:472`), the seeded review-only candidate always sets `empty: false` once `commits > 0` is confirmed, without checking whether the diff itself is actually empty (e.g. a commit immediately followed by a revert nets zero file changes). Such a branch would pass `Runner::review`'s validation and proceed into a review round with an empty patch, where `implement()`'s equivalent path would"
4739 }
4740]"####;
4741
4742 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4744<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4745
4746> [!IMPORTANT]
4747> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4748>
4749> This repository does not receive automatic reviews because it has fewer than 10 stars.
4750>
4751> <details>
4752> <summary>⚙️ Run configuration</summary>
4753>
4754> **Configuration used**: defaults
4755>
4756> **Review profile**: CHILL
4757>
4758> **Plan**: Team
4759>
4760> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4761>
4762> </details>
4763
4764<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4765
4766<!-- tips_start -->
4767
4768---
4769
4770Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=16)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
4771
4772<details>
4773<summary>❤️ Share</summary>
4774
4775- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20off"####;
4776
4777 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4779
4780---
4781### Reviewing PR #16
4782
4783- [x] Read AGENTS.md conventions
4784- [x] Review `src/daemon.rs` changes
4785- [x] Review `src/main.rs` changes (new `doctor` reporting)
4786- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4787- [x] Check test coverage for new behavior
4788- [x] Run verification commands (blocked — see note)
4789- [x] Post findings"####;
4790
4791 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4793
4794---
4795### Review: `magi review <branch>` — cheap-half-only graph
4796
4797Read through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.
4798
4799**Correctness**
4800
4801- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `"(existing branch)"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"####;
4802
4803 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4804 PrState {
4805 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4806 number: 16,
4807 state: PrLifecycle::Open,
4808 checks,
4809 blocking: if matches!(checks, Checks::Red) {
4813 Blocking::Yes
4814 } else {
4815 Blocking::No
4816 },
4817 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4818 review_comments: (0..comments)
4819 .map(|i| ReviewComment {
4820 author: "coderabbitai".to_owned(),
4821 path: Some("src/graph.rs".to_owned()),
4822 line: Some(231),
4823 body: format!("finding {i}"),
4824 })
4825 .collect(),
4826 }
4827 }
4828
4829 #[test]
4830 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4831 use CiExpectation::{Absent, Expected};
4832 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4833 let p = pr(checks, &["x"], 0);
4834 for waited in [Duration::ZERO, CHECKS_GRACE] {
4835 assert_eq!(
4836 decide_with(&p, 0, 4, waited, Expected),
4837 decide(&p, 0, 4, waited)
4838 );
4839 }
4840 }
4841 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4843 let p = pr(checks, &["x"], 0);
4844 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4845 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4846 }
4847 let mut p = pr(Checks::Unknown, &[], 0);
4849 p.blocking = Blocking::Conflict;
4850 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4851 p.state = PrLifecycle::Merged;
4853 assert_eq!(
4854 decide_with(&p, 0, 4, Duration::ZERO, Absent),
4855 Step::Done { merged: true }
4856 );
4857 }
4858
4859 #[test]
4860 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4861 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4862 assert_eq!(state.number, 10);
4863 assert_eq!(state.state, PrLifecycle::Open);
4864 assert_eq!(state.checks, Checks::Green);
4865 assert!(state.failing.is_empty());
4866 assert!(
4867 state.review_comments.is_empty(),
4868 "the only comment is CodeRabbit's trigger notice: {:?}",
4869 state.review_comments
4870 );
4871 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4872 }
4873
4874 #[test]
4875 fn a_failing_check_parses_as_red_and_is_named() {
4876 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4877 assert_eq!(state.checks, Checks::Red);
4878 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4879 let mut blocking = state.clone();
4886 blocking.blocking = Blocking::Yes;
4887 match decide(&blocking, 0, 4, Duration::ZERO) {
4888 Step::Fix { reason } => {
4889 assert!(reason.contains("editorconfig"), "reason: {reason}");
4890 assert!(reason.contains("failing"), "reason: {reason}");
4891 }
4892 other => panic!("expected a fix round, got {other:?}"),
4893 }
4894 }
4895
4896 #[test]
4897 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4898 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4899 assert_eq!(state.checks, Checks::Pending);
4900 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4901 }
4902
4903 #[test]
4904 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4905 let state = parse_pr(MERGED).expect("merged fixture parses");
4906 assert_eq!(state.state, PrLifecycle::Merged);
4907 assert_eq!(
4908 decide(&state, 0, 4, Duration::ZERO),
4909 Step::Done { merged: true }
4910 );
4911 }
4912
4913 #[test]
4914 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4915 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4916 assert_eq!(state.checks, Checks::Green);
4917 let authors: Vec<&str> = state
4918 .review_comments
4919 .iter()
4920 .map(|c| c.author.as_str())
4921 .collect();
4922 assert_eq!(
4923 authors,
4924 vec!["claude"],
4925 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4926 );
4927 match decide(&state, 0, 4, Duration::ZERO) {
4928 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4929 other => panic!("expected a fix round, got {other:?}"),
4930 }
4931 }
4932
4933 #[test]
4934 fn inline_review_comments_keep_their_file_and_line() {
4935 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4936 assert_eq!(comments.len(), 1);
4937 assert_eq!(comments[0].author, "claude[bot]");
4938 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4939 assert_eq!(comments[0].line, Some(231));
4940 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4941 }
4942
4943 #[test]
4944 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4945 assert!(
4946 is_noise(CODERABBIT_TRIGGER),
4947 "CodeRabbit's trigger notice declares itself not a review"
4948 );
4949 assert!(
4950 is_noise(CLAUDE_CHECKLIST),
4951 "a progress checklist asks for nothing"
4952 );
4953 assert!(
4954 !is_noise(CLAUDE_FINDING),
4955 "a review that names a bug is input, not noise"
4956 );
4957
4958 let mut clean = pr(Checks::Green, &[], 0);
4959 clean.review_comments.push(ReviewComment {
4960 author: "coderabbitai".to_owned(),
4961 path: None,
4962 line: None,
4963 body: CODERABBIT_TRIGGER.to_owned(),
4964 });
4965 clean.review_comments.retain(|c| !is_noise(&c.body));
4966 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4967
4968 let mut found = pr(Checks::Green, &[], 0);
4969 found.review_comments.push(ReviewComment {
4970 author: "claude".to_owned(),
4971 path: None,
4972 line: None,
4973 body: CLAUDE_FINDING.to_owned(),
4974 });
4975 found.review_comments.retain(|c| !is_noise(&c.body));
4976 assert!(matches!(
4977 decide(&found, 0, 4, Duration::ZERO),
4978 Step::Fix { .. }
4979 ));
4980 }
4981
4982 #[test]
4983 fn the_policy_table_holds_for_every_combination_that_matters() {
4984 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4985 (
4986 "pending checks are waited for, even on the last round",
4987 pr(Checks::Pending, &[], 0),
4988 4,
4989 4,
4990 Duration::ZERO,
4991 Step::Wait,
4992 ),
4993 (
4994 "red checks are fixed",
4995 pr(Checks::Red, &["editorconfig"], 0),
4996 0,
4997 4,
4998 Duration::ZERO,
4999 Step::Fix {
5000 reason: "1 check(s) failing: editorconfig".to_owned(),
5001 },
5002 ),
5003 (
5004 "green with comments is fixed, not merged",
5005 pr(Checks::Green, &[], 2),
5006 1,
5007 4,
5008 Duration::ZERO,
5009 Step::Fix {
5010 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5011 .to_owned(),
5012 },
5013 ),
5014 (
5015 "green and clean merges",
5016 pr(Checks::Green, &[], 0),
5017 3,
5018 4,
5019 Duration::ZERO,
5020 Step::Merge,
5021 ),
5022 (
5023 "an unreadable rollup is waited on while the grace lasts",
5024 pr(Checks::Unknown, &[], 0),
5025 0,
5026 4,
5027 Duration::ZERO,
5028 Step::Wait,
5029 ),
5030 (
5031 "an unreadable rollup is never merged once the grace is spent",
5032 pr(Checks::Unknown, &[], 0),
5033 0,
5034 4,
5035 CHECKS_GRACE,
5036 Step::GiveUp {
5037 reason: "no check status is readable on the pull request after 3 minute(s); \
5038 refusing to merge on a guess"
5039 .to_owned(),
5040 },
5041 ),
5042 ];
5043 for (what, state, round, budget, waited, want) in cases {
5044 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5045 }
5046 }
5047
5048 #[test]
5049 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5050 let green = parse_pr(GREEN_OPEN).expect("parse");
5054 assert_eq!(green.blocking, Blocking::No);
5055 let red = parse_pr(RED_OPEN).expect("parse");
5056 assert_eq!(
5057 red.blocking,
5058 Blocking::No,
5059 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5060 );
5061 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5062 let quiet =
5064 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5065 assert_eq!(quiet.blocking, Blocking::Unsaid);
5066 }
5067
5068 #[test]
5069 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5070 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5076 nonblocking.blocking = Blocking::No;
5077 assert_eq!(
5078 decide(&nonblocking, 0, 4, Duration::ZERO),
5079 Step::Merge,
5080 "the forge says nothing is in the way, so nothing is"
5081 );
5082
5083 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5085 blocking.blocking = Blocking::Yes;
5086 assert!(matches!(
5087 decide(&blocking, 0, 4, Duration::ZERO),
5088 Step::Fix { .. }
5089 ));
5090
5091 let mut commented = pr(Checks::Red, &["coverage"], 1);
5094 commented.blocking = Blocking::No;
5095 assert!(matches!(
5096 decide(&commented, 0, 4, Duration::ZERO),
5097 Step::Fix { .. }
5098 ));
5099
5100 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5102 unsaid.blocking = Blocking::Unsaid;
5103 assert!(matches!(
5104 decide(&unsaid, 0, 4, Duration::ZERO),
5105 Step::Fix { .. }
5106 ));
5107 }
5108
5109 #[test]
5110 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5111 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5112 red.blocking = Blocking::No;
5113 assert_eq!(
5114 decide(&red, 0, 4, Duration::ZERO),
5115 Step::Merge,
5116 "announcing must not change the decision"
5117 );
5118 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5119 assert!(said.contains("yukimemi/magi"), "{said}");
5120 assert!(said.contains("#16"), "{said}");
5121 assert!(
5122 said.contains("https://github.com/yukimemi/magi/pull/16"),
5123 "{said}"
5124 );
5125 assert!(
5126 said.contains("test (windows-latest)") && said.contains("coverage"),
5127 "{said}"
5128 );
5129
5130 let green = pr(Checks::Green, &["stale"], 0);
5132 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5133 }
5134
5135 #[test]
5136 fn the_repo_label_comes_from_the_pull_request_url() {
5137 let p = Path::new("/tmp/checkout");
5138 assert_eq!(
5139 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5140 "yukimemi/magi"
5141 );
5142 assert_eq!(repo_label(p, "not a url"), "checkout");
5143 }
5144
5145 #[test]
5146 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5147 let mut conflicted = pr(Checks::Green, &[], 0);
5152 conflicted.blocking = Blocking::Conflict;
5153 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5154
5155 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5159 red.blocking = Blocking::Conflict;
5160 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5161
5162 let mut merged = pr(Checks::Red, &[], 0);
5164 merged.blocking = Blocking::Conflict;
5165 merged.state = PrLifecycle::Merged;
5166 assert_eq!(
5167 decide(&merged, 0, 4, Duration::ZERO),
5168 Step::Done { merged: true }
5169 );
5170 }
5171
5172 #[test]
5173 fn the_forge_verdict_is_read_off_merge_state_status() {
5174 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5177 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5178 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5179 }
5180 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5181 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5182 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5183 for quiet in ["", "UNKNOWN"] {
5186 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5187 assert!(Blocking::of(quiet).stops_a_merge());
5188 }
5189 }
5190
5191 #[test]
5192 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5193 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5194 let jj = "could not determine current branch: failed to run git: not on any branch";
5196
5197 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5198 .expect("the forge says merged, so it merged");
5199 assert!(landed.ok);
5200 assert!(
5201 landed.detail.contains("but the pull request is merged"),
5202 "the record must not read as a clean success: {}",
5203 landed.detail
5204 );
5205 assert!(
5206 landed.detail.contains("not on any branch"),
5207 "and it must keep what the command actually said: {}",
5208 landed.detail
5209 );
5210
5211 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5213 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5214 assert!(merged_after_all(&argv, jj, None).is_none());
5216 }
5217
5218 #[test]
5219 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5220 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5221 state.state = PrLifecycle::Closed;
5222 assert_eq!(
5223 decide(&state, 0, 4, Duration::ZERO),
5224 Step::Done { merged: false },
5225 "a human closing the pull request ends the loop, whatever CI says"
5226 );
5227 }
5228
5229 #[test]
5230 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5231 let red = decide(
5232 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5233 4,
5234 4,
5235 Duration::ZERO,
5236 );
5237 match red {
5238 Step::GiveUp { reason } => {
5239 assert!(reason.contains("editorconfig"), "reason: {reason}");
5240 assert!(reason.contains("test (macos)"), "reason: {reason}");
5241 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5242 }
5243 other => panic!("expected a give-up, got {other:?}"),
5244 }
5245
5246 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5247 match commented {
5248 Step::GiveUp { reason } => {
5249 assert!(reason.contains("unresolved"), "reason: {reason}");
5250 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5251 }
5252 other => panic!("expected a give-up, got {other:?}"),
5253 }
5254 }
5255
5256 #[test]
5257 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5258 let candidate_commit = "magi: candidate A (uncommitted work)";
5259 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5260 let argv = merge_argv(16, &subject);
5261
5262 assert!(argv.contains(&"--squash".to_owned()));
5263 assert!(argv.contains(&"--delete-branch".to_owned()));
5264 assert!(argv.contains(&"--subject".to_owned()));
5265 assert_eq!(
5266 argv.last().map(String::as_str),
5267 Some("add retries to the uploader"),
5268 "the subject must not be the candidate commit message"
5269 );
5270 assert_ne!(subject, candidate_commit);
5271 }
5272
5273 #[test]
5274 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5275 assert_eq!(
5276 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5277 "feat: a queue, an unattended loop, and a phone UI"
5278 );
5279 assert_eq!(
5280 merge_subject("", "# port the retry logic\n\ndetails"),
5281 "port the retry logic",
5282 "an empty title falls back to the task's first line, heading marks stripped"
5283 );
5284 }
5285
5286 #[test]
5287 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5288 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5289 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5290 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5291 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5292 assert_eq!(run_of(""), None);
5293 }
5294
5295 #[test]
5296 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5297 let mut out = Vec::new();
5298 push_if_outstanding(
5299 &mut out,
5300 ReviewComment {
5301 author: "yukimemi".to_owned(),
5302 path: None,
5303 line: None,
5304 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5305 },
5306 );
5307 assert!(out.is_empty());
5308 }
5309
5310 fn run_state() -> RunState {
5314 let mut state = RunState::new(
5315 std::path::PathBuf::from("/repo/magi"),
5316 "main".to_owned(),
5317 "abcdef1234".to_owned(),
5318 "add retries to the uploader".to_owned(),
5319 crate::config::Config::default(),
5320 );
5321 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5326 let nanos = std::time::SystemTime::now()
5327 .duration_since(std::time::UNIX_EPOCH)
5328 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5329 state.id = format!(
5330 "20261004-{nanos:06}-{:04x}",
5331 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5332 );
5333 state
5334 }
5335
5336 fn green_pr() -> PrState {
5337 PrState {
5338 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5339 number: 42,
5340 state: PrLifecycle::Open,
5341 checks: Checks::Green,
5342 blocking: Blocking::No,
5344 failing: Vec::new(),
5345 review_comments: vec![ReviewComment {
5346 author: "coderabbitai".to_owned(),
5347 path: Some("src/land.rs".to_owned()),
5348 line: Some(212),
5349 body: "this branch never checks the exit code".to_owned(),
5350 }],
5351 }
5352 }
5353
5354 #[test]
5355 fn github_facing_land_text_is_english_whatever_the_language() {
5356 let mut state = run_state();
5357 state.config.graph.language = "ja".to_owned();
5358 let comment = stop_comment(&state.id, "checks are still red");
5359 assert!(comment.is_ascii(), "{comment}");
5360 assert!(comment.starts_with(MARKER));
5361
5362 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5363 let ja_at = p.find("Write all prose in ja").unwrap();
5364 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5365 assert!(ja_at < rule_at, "{p}");
5366 assert!(p.contains("stays in Japanese"), "{p}");
5367
5368 state.config.graph.language = "en".to_owned();
5369 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5370 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5371 assert!(!p.contains("does not apply"), "{p}");
5372 }
5373
5374 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5375
5376 fn panel() -> String {
5377 approval_panel(
5378 &run_state(),
5379 &green_pr(),
5380 NUMSTAT,
5381 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5382 &[
5383 "land: ask before merging".to_owned(),
5384 "land: colour the diff".to_owned(),
5385 ],
5386 "feat: merge approval from the phone",
5387 )
5388 }
5389
5390 #[test]
5391 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5392 let html = panel();
5393 for needle in [
5394 "42",
5395 "main",
5396 "src/land.rs",
5397 "src/web.rs",
5398 "assets/logo.png",
5399 "feat: merge approval from the phone",
5400 "land: ask before merging",
5401 "land: colour the diff",
5402 "coderabbitai",
5403 "this branch never checks the exit code",
5404 "green",
5405 ] {
5406 assert!(html.contains(needle), "the panel must state `{needle}`");
5407 }
5408 }
5409
5410 fn winning_candidate(summary: &str) -> Candidate {
5413 Candidate {
5414 index: 0,
5415 label: 'A',
5416 agent: "opus".to_owned(),
5417 branch: "magi/x/A".to_owned(),
5418 worktree: PathBuf::from("/wt/A"),
5419 summary: summary.to_owned(),
5420 stat: String::new(),
5421 files: 1,
5422 commits: 1,
5423 empty: false,
5424 failed: None,
5425 verified_noop: None,
5426 duration_ms: 0,
5427 folded: false,
5428 }
5429 }
5430
5431 fn uncontested_tally() -> Tally {
5432 Tally {
5433 first_choice: BTreeMap::from([('A', 1)]),
5434 borda: BTreeMap::new(),
5435 winner: 'A',
5436 rankings: 1,
5437 unanimous_initial: true,
5438 deliberated: false,
5439 changed_votes: 0,
5440 unanimous_final: true,
5441 tie_break: None,
5442 judges: 1,
5443 present: 1,
5444 quorum: 1,
5445 met_quorum: true,
5446 uncontested: None,
5447 }
5448 }
5449
5450 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5451 ReviewRecord {
5452 attempts: 0,
5453 reviewer,
5454 agent: agent.to_owned(),
5455 summary: summary.to_owned(),
5456 findings: Vec::new(),
5457 vote: None,
5458 failed: None,
5459 duration_ms: 0,
5460 }
5461 }
5462
5463 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5464 let answered = reviews.len();
5465 ReviewRound {
5466 round,
5467 head: "abc1234".to_owned(),
5468 verified_head: None,
5469 verified_at: None,
5470 reviews,
5471 e2e: Vec::new(),
5472 verify_retried: false,
5473 e2e_deferred: false,
5474 e2e_defer_reason: None,
5475 fix: None,
5476 blocking: 0,
5477 answered,
5478 expected: answered,
5479 clean: true,
5480 progressed: false,
5481 vote_split: false,
5482 reconsideration: Vec::new(),
5483 verdict: None,
5484 }
5485 }
5486
5487 #[test]
5488 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5489 let en = panel();
5490 assert!(en.contains("Task"), "{en}");
5491 assert!(en.contains("add retries to the uploader"), "{en}");
5492
5493 let mut state = run_state();
5494 state.config.graph.language = "ja".to_owned();
5495 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5496 assert!(ja.contains("タスク"), "{ja}");
5497 assert!(
5498 ja.contains("add retries to the uploader"),
5499 "the task itself is not translated: {ja}"
5500 );
5501 }
5502
5503 #[test]
5504 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5505 let html = panel();
5509 assert!(!html.contains("What changed"), "{html}");
5510 assert!(!html.contains("Review verdict"), "{html}");
5511 }
5512
5513 #[test]
5514 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5515 let mut state = run_state();
5516 state.candidates = vec![winning_candidate("")];
5517 state.tally = Some(uncontested_tally());
5518 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5519 assert!(
5520 !html.contains("What changed"),
5521 "an empty summary must not render an empty box: {html}"
5522 );
5523 }
5524
5525 #[test]
5526 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5527 let mut state = run_state();
5528 state.candidates = vec![winning_candidate(
5529 "Added a retry loop around the uploader PUT call.",
5530 )];
5531 state.tally = Some(uncontested_tally());
5532 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5533 assert!(en.contains("What changed"), "{en}");
5534 assert!(
5535 en.contains("Added a retry loop around the uploader PUT call."),
5536 "{en}"
5537 );
5538
5539 state.config.graph.language = "ja".to_owned();
5540 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5541 assert!(ja.contains("変更内容"), "{ja}");
5542 assert!(
5543 ja.contains("Added a retry loop around the uploader PUT call."),
5544 "{ja}"
5545 );
5546 }
5547
5548 #[test]
5549 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5550 let mut state = run_state();
5551 state.reviews = vec![
5552 review_round(
5553 1,
5554 vec![review_record(1, "alpha", "found a race, sent back")],
5555 ),
5556 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5557 ];
5558 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5559 assert!(en.contains("Review verdict"), "{en}");
5560 assert!(en.contains("race is fixed, clean"), "{en}");
5561 assert!(
5562 !en.contains("found a race, sent back"),
5563 "only the round that actually cleared the merge should show: {en}"
5564 );
5565
5566 state.config.graph.language = "ja".to_owned();
5567 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5568 assert!(ja.contains("レビューの結論"), "{ja}");
5569 assert!(ja.contains("レビュアー"), "{ja}");
5570 assert!(ja.contains("race is fixed, clean"), "{ja}");
5571 }
5572
5573 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5579 ReviewRecord {
5580 attempts: 0,
5581 reviewer,
5582 agent: agent.to_owned(),
5583 summary: String::new(),
5584 findings: Vec::new(),
5585 vote: None,
5586 failed: Some(reason.to_owned()),
5587 duration_ms: 0,
5588 }
5589 }
5590
5591 #[test]
5592 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5593 let mut state = run_state();
5594 state.reviews = vec![review_round(
5595 1,
5596 vec![
5597 review_record(1, "alpha", "clean, nothing to add"),
5598 unanswered_review_record(2, "beta", "timed out"),
5599 ],
5600 )];
5601 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5602 assert!(en.contains("clean, nothing to add"), "{en}");
5603 assert!(
5604 en.contains("produced no answer: timed out"),
5605 "a seat that never answered must say so, not render a blank box: {en}"
5606 );
5607 assert!(
5608 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5609 "no reviewer box may be left empty: {en}"
5610 );
5611
5612 state.config.graph.language = "ja".to_owned();
5613 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5614 assert!(ja.contains("回答なし: timed out"), "{ja}");
5615 }
5616
5617 #[test]
5618 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5619 let html = panel();
5620 assert!(!html.contains("<script"), "no script survives the csp");
5621 assert!(!html.contains("<form"), "form-action is 'none'");
5622 let pr = green_pr();
5623 assert_eq!(
5624 html.matches("http").count(),
5625 html.matches(pr.url.as_str()).count(),
5626 "the only http url in the panel is the pull request's own link"
5627 );
5628 }
5629
5630 #[test]
5631 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5632 let html = panel();
5633 assert!(
5634 html.contains(">+</span>"),
5635 "an added line carries a `+` in the gutter, not only a background"
5636 );
5637 assert!(
5638 html.contains(">-</span>"),
5639 "a removed line carries a `-` in the gutter, not only a background"
5640 );
5641 assert!(
5642 html.contains(">new line</span>"),
5643 "the marker is moved to the gutter, so the body is printed once without it"
5644 );
5645 }
5646
5647 #[test]
5648 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5649 let total = DIFF_MAX_LINES + 100;
5650 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5651 let html = approval_panel(
5652 &run_state(),
5653 &green_pr(),
5654 NUMSTAT,
5655 &diff,
5656 &[],
5657 "feat: something long",
5658 );
5659 assert!(
5660 html.contains(&format!("100 of {total} diff lines omitted")),
5661 "the note must say exactly how much was cut"
5662 );
5663 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5664 assert!(
5665 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5666 "nothing past the threshold is rendered"
5667 );
5668 assert!(
5669 html.contains("/repo/magi"),
5670 "the note says where the rest is"
5671 );
5672 }
5673
5674 #[test]
5675 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5676 let html = approval_panel(
5677 &run_state(),
5678 &green_pr(),
5679 "1\t2\tsrc/<b>&\"x\"'.rs",
5680 "",
5681 &[],
5682 "subject",
5683 );
5684 assert!(html.contains("src/<b>&"x"'.rs"));
5685 assert!(
5686 !html.contains("<b>"),
5687 "an agent-influenced path must never become markup"
5688 );
5689 }
5690
5691 #[tokio::test]
5692 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5693 let a = std::path::PathBuf::from("/repo/a");
5694 let b = std::path::PathBuf::from("/repo/b");
5695
5696 let held = repo_merge_lock(&a).lock_owned().await;
5697
5698 assert!(
5701 repo_merge_lock(&a).try_lock().is_err(),
5702 "a second merge into the same repository must not proceed concurrently"
5703 );
5704
5705 assert!(
5709 repo_merge_lock(&b).try_lock().is_ok(),
5710 "a different repository's merge lock must be independent"
5711 );
5712
5713 drop(held);
5714 assert!(
5715 repo_merge_lock(&a).try_lock().is_ok(),
5716 "the lock is released once the holder is done"
5717 );
5718 }
5719
5720 #[test]
5721 fn only_the_merge_choice_merges_and_silence_holds() {
5722 let table = [
5723 (None, Approval::Hold),
5724 (Some("merge"), Approval::Merge),
5725 (Some(" merge\n"), Approval::Merge),
5726 (Some("hold"), Approval::Hold),
5727 (Some(""), Approval::Hold),
5728 (Some("yes"), Approval::Hold),
5729 ];
5730 for (answer, want) in table {
5731 assert_eq!(
5732 approval(answer),
5733 want,
5734 "answer {answer:?} must resolve to {want:?}"
5735 );
5736 }
5737 }
5738
5739 #[tokio::test]
5740 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5741 let mut state = landing_state();
5742 state.config.graph.land_approval = true;
5743 let pr = green_pr();
5744
5745 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5746 .await
5747 .unwrap();
5748 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5749 assert!(
5750 !state.parked,
5751 "approval_gate itself never sets `parked`; only its caller does"
5752 );
5753
5754 let store = ask::Questions::open();
5755 let filed: Vec<_> = store
5756 .list()
5757 .into_iter()
5758 .filter(|q| q.run == state.id)
5759 .collect();
5760 assert_eq!(filed.len(), 1, "exactly one question is filed");
5761 assert_eq!(filed[0].node, APPROVAL_NODE);
5762 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5763 assert!(filed[0].status.open());
5764
5765 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5769 .await
5770 .unwrap();
5771 assert_eq!(again, ApprovalGate::Pending);
5772 let still_one = store
5773 .list()
5774 .into_iter()
5775 .filter(|q| q.run == state.id)
5776 .count();
5777 assert_eq!(
5778 still_one, 1,
5779 "asking twice must not double-file the question"
5780 );
5781 }
5782
5783 #[tokio::test]
5784 async fn approving_the_existing_question_is_read_back_as_approved() {
5785 crate::run::pin_test_home();
5786 let mut state = run_state();
5787 state.config.graph.land_approval = true;
5788 let pr = green_pr();
5789 assert_eq!(
5790 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5791 .await
5792 .unwrap(),
5793 ApprovalGate::Pending
5794 );
5795
5796 let store = ask::Questions::open();
5797 let mut q = store
5798 .list()
5799 .into_iter()
5800 .find(|q| q.run == state.id)
5801 .expect("filed above");
5802 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5803 store.put(&mut q).unwrap();
5804
5805 assert_eq!(
5806 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5807 .await
5808 .unwrap(),
5809 ApprovalGate::Approved
5810 );
5811 }
5812
5813 #[tokio::test]
5814 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5815 crate::run::pin_test_home();
5816 let store = ask::Questions::open();
5817
5818 let mut held_state = run_state();
5819 held_state.config.graph.land_approval = true;
5820 let pr = green_pr();
5821 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5822 .await
5823 .unwrap();
5824 let mut q = store
5825 .list()
5826 .into_iter()
5827 .find(|q| q.run == held_state.id)
5828 .expect("filed above");
5829 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5830 store.put(&mut q).unwrap();
5831 assert_eq!(
5832 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5833 .await
5834 .unwrap(),
5835 ApprovalGate::Held
5836 );
5837
5838 let mut abandoned_state = run_state();
5839 abandoned_state.config.graph.land_approval = true;
5840 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5841 .await
5842 .unwrap();
5843 let mut q = store
5844 .list()
5845 .into_iter()
5846 .find(|q| q.run == abandoned_state.id)
5847 .expect("filed above");
5848 q.abandon("no answer within the timeout");
5849 store.put(&mut q).unwrap();
5850 assert_eq!(
5851 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5852 .await
5853 .unwrap(),
5854 ApprovalGate::Held,
5855 "silence must never merge"
5856 );
5857 }
5858
5859 fn contested() -> ContestedHandoff {
5860 let finding = |id: &str, n: u32| crate::verdict::Finding {
5861 id: id.to_owned(),
5862 severity: crate::verdict::Severity::Major,
5863 file: Some("src/a.rs".to_owned()),
5864 line: Some(n),
5865 title: format!("problem {id}"),
5866 detail: String::new(),
5867 };
5868 ContestedHandoff {
5869 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5870 rejecters: vec![(1, "alpha".to_owned())],
5871 }
5872 }
5873
5874 #[test]
5875 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5876 let mut state = run_state();
5877 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5878 state.contested_handoff = Some(contested());
5879 assert!(contested_to_ask(&state).is_some());
5880 state.config.graph.hold_contested_merge = false;
5881 assert!(
5882 contested_to_ask(&state).is_none(),
5883 "the switch restores today"
5884 );
5885 }
5886
5887 #[test]
5888 fn merge_intent_wants_a_clear_unhedged_quote_and_holds_on_doubt() {
5889 let yes = [
5890 ("merge", "merge"),
5891 (" Merge ", "Merge"),
5892 (
5893 "マージしていいよ。残りのレビュー指摘はフォローアップタスクとして積んで",
5894 "マージしていいよ",
5895 ),
5896 (
5897 "Merge it. Please file the remaining findings as follow-ups.",
5898 "Merge it",
5899 ),
5900 ("Note the findings and merge now", "merge now"),
5901 ("I know the risk, merge it", "merge it"),
5902 ];
5903 for (msg, quote) in yes {
5904 assert!(merge_intent(msg, quote), "{msg:?} / {quote:?}");
5905 }
5906 let no = [
5907 ("たぶんマージでいい", "たぶんマージでいい"),
5908 (
5909 "マージしていいかも。フォローアップ積んで",
5910 "マージしていいかも",
5911 ),
5912 ("maybe merge it", "merge it"),
5913 ("probably fine to merge", "merge"),
5914 ("merge if CI is green", "merge"),
5915 ("CIが通ったらマージして", "マージして"),
5916 ("merge, but not the docs change", "merge"),
5917 ("don't merge", "merge"),
5918 ("merge?", "merge"),
5919 ("マージしていい?", "マージしていい"),
5920 ("merge it. wait, actually hold on", "merge it"),
5921 ("マージして。やっぱりやめた", "マージして"),
5922 ("please file follow-ups", "follow-ups"),
5923 (
5924 "Merge it. Only if CI passes. Queue the remaining findings.",
5925 "Merge it",
5926 ),
5927 ("マージして。CIが通ったらね。", "マージして"),
5928 ("Merge it. Don't.", "Merge it"),
5929 ("Merge it. Hold on a sec.", "Merge it"),
5930 ("マージして。でも保留で", "マージして"),
5931 ("マージしていいよ、でもdocsは触らないで", "マージしていいよ"),
5932 (
5933 "Merge once CI passes. Queue the remaining findings.",
5934 "Merge once CI passes",
5935 ),
5936 ("Merge provided CI passes.", "Merge provided CI passes"),
5937 ("CIが通り次第マージして", "マージして"),
5938 ("Merge it. No, stop.", "Merge it"),
5939 ("Merge it. Stop.", "Merge it"),
5940 ("Merge it. Nope.", "Merge it"),
5941 ("merge it, don't", "merge it"),
5942 ("merge it, dont", "merge it"),
5943 ("マージして。いや、やめて", "マージして"),
5944 (
5949 "Merge now. File a follow-up task to fix R1-1 after this PR merges.",
5950 "Merge now",
5951 ),
5952 ("merge it", "go ahead"),
5953 ("merge it", "merge it please"),
5954 ("merge it", " "),
5955 ];
5956 for (msg, quote) in no {
5957 assert!(!merge_intent(msg, quote), "{msg:?} / {quote:?}");
5958 }
5959 }
5960
5961 #[test]
5962 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5963 let q = ask::Question::new(
5964 "run-1".to_owned(),
5965 APPROVAL_NODE.to_owned(),
5966 "land".to_owned(),
5967 "Merge?".to_owned(),
5968 String::new(),
5969 vec![APPROVE.to_owned(), HOLD.to_owned()],
5970 );
5971 let none = deputy_brief(&q, None);
5972 assert!(none.contains("could not be read"), "{none}");
5973 assert!(none.contains("Silence is a hold"), "{none}");
5974
5975 let mut state = run_state();
5976 state.pr = Some(crate::run::PrRecord {
5977 url: "https://example.test/pull/7".to_owned(),
5978 number: 7,
5979 state: "open".to_owned(),
5980 checks: "green".to_owned(),
5981 round: 0,
5982 rounds: 3,
5983 red_at_merge: Vec::new(),
5984 });
5985 state.contested_handoff = Some(contested());
5986 let b = deputy_brief(&q, Some(&state));
5987 assert!(b.contains("https://example.test/pull/7"), "{b}");
5988 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5989 assert!(b.contains("#1"), "the rejecting seat: {b}");
5990 state.contested_handoff = None;
5991 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5992 }
5993
5994 #[test]
5995 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5996 for lang in ["en", "ja"] {
5997 let mut cfg = crate::config::Config::default();
5998 cfg.graph.language = lang.to_owned();
5999 let w = words(&cfg.graph.language);
6000 let text = w.approval_detail(
6001 "https://github.com/yukimemi/magi/pull/42",
6002 "main",
6003 "feat: x",
6004 Some(&contested()),
6005 );
6006 assert!(text.contains("pull/42"), "{text}");
6007 assert!(
6008 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6009 "{text}"
6010 );
6011 assert!(text.contains("R3-1-5"), "{text}");
6012 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6013 assert!(text.contains("2"), "the rest are counted: {text}");
6014 assert!(text.contains("#1 (alpha)"), "{text}");
6015 }
6016 let plain = words("en").approval_detail("u", "main", "s", None);
6017 assert!(!plain.contains("reject"), "{plain}");
6018 }
6019
6020 #[tokio::test]
6021 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6022 crate::run::pin_test_home();
6023 let mut state = run_state();
6024 state.config.graph.land_approval = false;
6025 state.contested_handoff = Some(contested());
6026 let pr = green_pr();
6027 let c = contested_to_ask(&state);
6028 assert_eq!(
6029 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6030 .await
6031 .unwrap(),
6032 ApprovalGate::Pending,
6033 "silence is a hold"
6034 );
6035 let store = ask::Questions::open();
6036 let filed: Vec<_> = store
6037 .list()
6038 .into_iter()
6039 .filter(|q| q.run == state.id)
6040 .collect();
6041 assert_eq!(filed.len(), 1);
6042 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6043
6044 assert_eq!(
6045 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6046 .await
6047 .unwrap(),
6048 ApprovalGate::Pending
6049 );
6050 let mut q = store
6051 .list()
6052 .into_iter()
6053 .find(|q| q.run == state.id)
6054 .unwrap();
6055 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6056 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6057 store.put(&mut q).unwrap();
6058 assert_eq!(
6059 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6060 .await
6061 .unwrap(),
6062 ApprovalGate::Approved
6063 );
6064 }
6065
6066 #[test]
6067 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6068 let rows = parse_numstat(NUMSTAT);
6069 assert_eq!(
6070 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6071 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6072 );
6073 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6074 }
6075 #[test]
6076 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6077 let mut state = run_state();
6081 state.config.graph.language = "ja".to_owned();
6082 let pr = green_pr();
6083 let commits = ["c1".to_owned()];
6084
6085 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6086 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6087 assert!(ja.contains("squash されるコミット"), "{ja}");
6088 assert!(ja.contains("レビューコメント"), "{ja}");
6089 assert!(ja.contains("差分"), "{ja}");
6090 assert!(
6091 !ja.contains("Commits being squashed"),
6092 "no English left over"
6093 );
6094
6095 let w = words("ja");
6096 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6097 assert!(
6098 w.approval_detail("http://x/1", "main", "feat: x", None)
6099 .contains("パネル")
6100 );
6101
6102 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6104 assert!(ja.contains("feat: x"), "nor is the merge subject");
6105
6106 state.config.graph.language = "en".to_owned();
6109 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6110 assert!(en.contains("Commits being squashed"), "{en}");
6111 assert_eq!(words("Klingon").html_lang, "en");
6112 }
6113
6114 #[test]
6118 fn pick_open_pr_classifies_by_count_and_base() {
6119 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6120 assert_eq!(
6121 pick_open_pr(one, "main").unwrap(),
6122 OpenPr::One {
6123 url: "https://x/pull/58".into(),
6124 title: "t".into()
6125 }
6126 );
6127 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6128 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6129 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6130 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6131 assert_eq!(
6132 pick_open_pr(two, "main").unwrap(),
6133 OpenPr::Many(vec!["u1".into(), "u2".into()])
6134 );
6135 assert!(pick_open_pr("not json", "main").is_err());
6136 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6138 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6139 }
6140
6141 #[test]
6142 fn pick_merged_pr_picks_the_unique_match() {
6143 let json = r#"[
6144 {"url": "https://github.com/o/r/pull/42", "number": 42,
6145 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6146 ]"#;
6147 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6148 let found = pick_merged_pr(json, "main", created_at)
6149 .expect("valid json")
6150 .expect("one unambiguous match");
6151 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6152 assert_eq!(found.number, 42);
6153 }
6154
6155 #[test]
6159 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6160 let json = r#"[
6161 {"url": "https://github.com/o/r/pull/42", "number": 42,
6162 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6163 {"url": "https://github.com/o/r/pull/43", "number": 43,
6164 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6165 ]"#;
6166 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6167 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6168 }
6169
6170 #[test]
6174 fn pick_merged_pr_ignores_a_different_base_branch() {
6175 let json = r#"[
6176 {"url": "https://github.com/o/r/pull/42", "number": 42,
6177 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6178 ]"#;
6179 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6180 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6181 }
6182
6183 #[test]
6186 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6187 let json = r#"[
6188 {"url": "https://github.com/o/r/pull/42", "number": 42,
6189 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6190 ]"#;
6191 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6192 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6193 }
6194
6195 #[test]
6196 fn slug_of_pr_url_reads_host_owner_and_repo() {
6197 assert_eq!(
6198 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6199 Some("github.com/yukimemi/shun")
6200 );
6201 }
6202
6203 #[test]
6204 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6205 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6206 assert_eq!(slug_of_pr_url("not a url at all"), None);
6207 assert_eq!(slug_of_pr_url("https://github.com"), None);
6208 }
6209
6210 #[test]
6211 fn slug_of_repo_url_reads_host_owner_and_repo() {
6212 assert_eq!(
6213 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6214 Some("github.com/yukimemi/magi")
6215 );
6216 assert_eq!(slug_of_repo_url("https://github.com"), None);
6217 }
6218
6219 #[test]
6220 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6221 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6222 .expect("same repo, different case");
6223 }
6224
6225 #[test]
6230 fn ensure_same_repo_refuses_a_different_repo() {
6231 let err =
6232 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6233 let msg = format!("{err:#}");
6234 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6235 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6236 }
6237
6238 #[test]
6243 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6244 let err = ensure_same_repo(
6245 "github.com/yukimemi/magi",
6246 "github.example.com/yukimemi/magi",
6247 )
6248 .unwrap_err();
6249 let msg = format!("{err:#}");
6250 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6251 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6252 }
6253
6254 #[tokio::test]
6259 async fn find_external_merge_returns_none_without_a_winner() {
6260 let state = RunState::new(
6261 PathBuf::from("/no/such/repo"),
6262 "main".to_owned(),
6263 "0000000000000000000000000000000000000000".to_owned(),
6264 "irrelevant".to_owned(),
6265 crate::config::Config::default(),
6266 );
6267 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6268 }
6269
6270 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6271 let mut run = RunState::new(
6272 PathBuf::from(repo),
6273 "main".to_owned(),
6274 "abcdef1234".to_owned(),
6275 "x".to_owned(),
6276 crate::config::Config::default(),
6277 );
6278 run.id = id.to_owned();
6279 run.status = status;
6280 run.pr = Some(crate::run::PrRecord {
6281 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6282 url: url.to_owned(),
6283 state: state.to_owned(),
6284 checks: "red".to_owned(),
6285 round: 0,
6286 rounds: 2,
6287 red_at_merge: Vec::new(),
6288 });
6289 run.save_under(home).unwrap();
6290 }
6291
6292 fn recorded(home: &Path, id: &str) -> String {
6293 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6294 serde_json::from_str::<RunState>(&body)
6295 .unwrap()
6296 .pr
6297 .unwrap()
6298 .state
6299 }
6300
6301 const PR: &str = "https://github.com/o/r/pull/7";
6302
6303 #[test]
6304 fn write_through_updates_predecessors_and_siblings_only() {
6305 let tmp = tempfile::tempdir().unwrap();
6306 let h = tmp.path();
6307 pr_run(
6308 h,
6309 "20261004-100000-aaaa",
6310 "/repo/r",
6311 RunStatus::Superseded,
6312 PR,
6313 "open",
6314 );
6315 pr_run(
6316 h,
6317 "20261004-100100-bbbb",
6318 "/repo/r",
6319 RunStatus::Blocked,
6320 PR,
6321 "open",
6322 );
6323 pr_run(
6325 h,
6326 "20261004-100200-cccc",
6327 "/repo/r",
6328 RunStatus::Landing,
6329 PR,
6330 "open",
6331 );
6332 pr_run(
6334 h,
6335 "20261004-100300-dddd",
6336 "/repo/other",
6337 RunStatus::Blocked,
6338 "https://github.com/o/other/pull/7",
6339 "open",
6340 );
6341 pr_run(
6343 h,
6344 "20261004-100400-eeee",
6345 "/repo/r",
6346 RunStatus::Blocked,
6347 "https://github.com/o/r/pull/8",
6348 "open",
6349 );
6350 pr_run(
6351 h,
6352 "20261004-100500-ffff",
6353 "/repo/r",
6354 RunStatus::Merged,
6355 PR,
6356 "open",
6357 );
6358 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6359
6360 assert_eq!(
6361 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6362 2
6363 );
6364 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6365 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6366 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6367 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6368 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6369 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6371 assert_eq!(
6373 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6374 0
6375 );
6376 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6377 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6378 }
6379
6380 #[test]
6381 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6382 let tmp = tempfile::tempdir().unwrap();
6383 let h = tmp.path();
6384 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6385 pr_run(
6386 h,
6387 "20261004-100000-aaaa",
6388 "/repo/r",
6389 RunStatus::Superseded,
6390 &url(1),
6391 "open",
6392 );
6393 pr_run(
6394 h,
6395 "20261004-100100-bbbb",
6396 "/repo/r",
6397 RunStatus::Blocked,
6398 &url(2),
6399 "open",
6400 );
6401 pr_run(
6402 h,
6403 "20261004-100200-cccc",
6404 "/repo/r",
6405 RunStatus::Ready,
6406 &url(3),
6407 "open",
6408 );
6409 pr_run(
6410 h,
6411 "20261004-100300-dddd",
6412 "/repo/r",
6413 RunStatus::Ready,
6414 &url(4),
6415 "open",
6416 );
6417 pr_run(
6418 h,
6419 "20261004-100400-eeee",
6420 "/repo/r",
6421 RunStatus::Implementing,
6422 &url(1),
6423 "open",
6424 );
6425 assert_eq!(stale_open_prs(h).len(), 4);
6426
6427 let mut known = BTreeMap::new();
6428 known.insert(url(1), PrLifecycle::Merged);
6429 known.insert(url(2), PrLifecycle::Closed);
6430 known.insert(url(3), PrLifecycle::Open);
6431 assert_eq!(apply_pr_states(h, &known), 2);
6433 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6434 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6435 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6436 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6437 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6438 assert_eq!(apply_pr_states(h, &known), 0);
6439 }
6440
6441 use std::collections::VecDeque;
6444 use std::sync::Mutex;
6445
6446 struct Scripted {
6449 views: Mutex<VecDeque<Seen>>,
6450 merges: Mutex<VecDeque<(bool, String)>>,
6451 fix: Mutex<Option<Fixed>>,
6452 log: Mutex<Vec<&'static str>>,
6453 argvs: Mutex<Vec<Vec<String>>>,
6454 required: Mutex<Option<BTreeSet<String>>>,
6455 merged: Mutex<bool>,
6458 queued: Mutex<bool>,
6459 unreadable_after_merge: Mutex<bool>,
6461 }
6462
6463 impl Scripted {
6464 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6465 Self {
6466 views: Mutex::new(views.into()),
6467 merges: Mutex::new(
6468 merges
6469 .into_iter()
6470 .map(|(ok, m)| (ok, m.to_owned()))
6471 .collect(),
6472 ),
6473 fix: Mutex::new(None),
6474 log: Mutex::new(Vec::new()),
6475 argvs: Mutex::new(Vec::new()),
6476 required: Mutex::new(None),
6477 merged: Mutex::new(false),
6478 queued: Mutex::new(false),
6479 unreadable_after_merge: Mutex::new(false),
6480 }
6481 }
6482 fn argvs(&self) -> Vec<Vec<String>> {
6483 self.argvs.lock().unwrap().clone()
6484 }
6485 fn calls(&self) -> Vec<&'static str> {
6486 self.log.lock().unwrap().clone()
6487 }
6488 }
6489
6490 impl Forge for Scripted {
6491 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6492 self.log.lock().unwrap().push("view");
6493 if *self.unreadable_after_merge.lock().unwrap()
6494 && !self.argvs.lock().unwrap().is_empty()
6495 {
6496 anyhow::bail!("forge unreachable");
6497 }
6498 let mut v = self.views.lock().unwrap();
6499 let mut seen = if v.len() > 1 {
6500 v.pop_front().unwrap()
6501 } else {
6502 v[0].clone()
6503 };
6504 if *self.merged.lock().unwrap() {
6505 seen.pr.state = PrLifecycle::Merged;
6506 }
6507 Ok(seen)
6508 }
6509 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6510 self.log.lock().unwrap().push("merge");
6511 self.argvs.lock().unwrap().push(argv.to_vec());
6512 let out = self
6513 .merges
6514 .lock()
6515 .unwrap()
6516 .pop_front()
6517 .expect("unscripted merge");
6518 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6519 {
6520 *self.merged.lock().unwrap() = true;
6521 }
6522 Ok(out)
6523 }
6524 async fn poll(&self) {
6525 self.log.lock().unwrap().push("poll");
6526 }
6527 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6528 self.required.lock().unwrap().clone()
6529 }
6530 async fn fix(
6531 &self,
6532 _state: &mut RunState,
6533 _pr: &PrState,
6534 _round: usize,
6535 _budget: usize,
6536 _reason: &str,
6537 _logs: &str,
6538 ) -> Result<Fixed> {
6539 self.log.lock().unwrap().push("fix");
6540 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6541 }
6542 }
6543
6544 const REFUSED: &str =
6545 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6546
6547 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6548 let mut pr = green_pr();
6549 pr.checks = checks;
6550 pr.blocking = Blocking::of(merge_state);
6551 if !comments {
6552 pr.review_comments.clear();
6553 }
6554 Seen {
6555 pr,
6556 title: "feat: x".to_owned(),
6557 failing_urls: Vec::new(),
6558 head: head.to_owned(),
6559 rollup_head: head.to_owned(),
6560 merge_state: merge_state.to_owned(),
6561 contexts: Vec::new(),
6562 base: "main".to_owned(),
6563 }
6564 }
6565
6566 fn landing_state() -> RunState {
6567 crate::run::pin_test_home();
6568 let mut state = run_state();
6569 state.config.graph.land_approval = false;
6570 state
6571 }
6572
6573 #[test]
6574 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6575 assert!(!awaiting_new_head(None, "aaa"));
6576 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6577 assert!(awaiting_new_head(Some("abc123"), "def456"));
6578 assert!(awaiting_new_head(Some("abc123"), ""));
6579 }
6580
6581 #[test]
6582 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6583 let open = |c, m: &str| seen("a", c, m, false);
6584 let table = [
6585 (None, false, Refused::Pending),
6586 (
6587 Some(open(Checks::Pending, "BLOCKED")),
6588 false,
6589 Refused::Pending,
6590 ),
6591 (
6592 Some(open(Checks::Unknown, "BLOCKED")),
6593 false,
6594 Refused::Pending,
6595 ),
6596 (
6597 Some(open(Checks::Green, "UNKNOWN")),
6598 false,
6599 Refused::Pending,
6600 ),
6601 (Some(open(Checks::Green, "")), false, Refused::Pending),
6602 (
6603 Some(open(Checks::Green, "BLOCKED")),
6604 false,
6605 Refused::Recheck,
6606 ),
6607 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6608 ];
6609 for (after, rechecked, want) in table {
6610 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6611 }
6612 let mut closed = open(Checks::Green, "CLEAN");
6613 closed.pr.state = PrLifecycle::Closed;
6614 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6615 }
6616
6617 #[tokio::test]
6618 async fn a_normal_landing_merges_on_the_first_look() {
6619 let mut state = landing_state();
6620 let forge = Scripted::new(
6621 vec![seen("a", Checks::Green, "CLEAN", false)],
6622 vec![(true, "")],
6623 );
6624 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6625 .await
6626 .unwrap();
6627 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6629 assert_eq!(state.status, RunStatus::Merged);
6630 }
6631
6632 #[tokio::test]
6633 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6634 let mut state = landing_state();
6635 let forge = Scripted::new(
6636 vec![seen("a", Checks::Green, "CLEAN", false)],
6637 std::iter::repeat_n((true, ""), 100).collect(),
6638 );
6639 *forge.queued.lock().unwrap() = true;
6640 let task = async {
6641 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6642 .await
6643 .unwrap();
6644 };
6645 task.await;
6648 assert_ne!(state.status, RunStatus::Merged);
6649 }
6650
6651 #[tokio::test]
6652 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6653 let mut state = landing_state();
6654 let forge = Scripted::new(
6655 vec![seen("a", Checks::Green, "CLEAN", false)],
6656 std::iter::repeat_n((true, ""), 100).collect(),
6657 );
6658 *forge.unreadable_after_merge.lock().unwrap() = true;
6659 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6660 .await
6661 .ok();
6662 assert_ne!(state.status, RunStatus::Merged);
6663 }
6664
6665 #[tokio::test]
6666 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6667 let mut state = landing_state();
6668 let forge = Scripted::new(
6669 vec![
6670 seen("old", Checks::Green, "CLEAN", true),
6671 seen("old", Checks::Green, "CLEAN", true),
6673 seen("new", Checks::Pending, "BLOCKED", true),
6674 seen("new", Checks::Green, "CLEAN", true),
6675 ],
6676 vec![(true, "")],
6677 );
6678 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6679 head: "NEW".to_owned(),
6680 });
6681 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6682 .await
6683 .unwrap();
6684 assert_eq!(
6685 forge.calls(),
6686 [
6687 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6688 "view"
6689 ]
6690 );
6691 assert_eq!(state.status, RunStatus::Merged);
6692 }
6693
6694 #[tokio::test]
6695 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6696 let mut state = landing_state();
6697 let forge = Scripted::new(
6698 vec![
6699 seen("old", Checks::Green, "CLEAN", true),
6700 seen("someone-elses", Checks::Green, "CLEAN", true),
6701 ],
6702 vec![],
6703 );
6704 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6705 head: "mine".to_owned(),
6706 });
6707 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6708 .await
6709 .unwrap();
6710 assert!(!forge.calls().contains(&"merge"));
6711 let why = state.merge.as_ref().unwrap().detail.clone();
6712 assert!(
6713 why.contains("mine") && why.contains("someone-elses"),
6714 "{why}"
6715 );
6716 assert_eq!(state.status, RunStatus::Blocked);
6717 }
6718
6719 #[tokio::test]
6720 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6721 let mut state = landing_state();
6722 let forge = Scripted::new(
6723 vec![
6724 seen("a", Checks::Green, "CLEAN", false),
6725 seen("a", Checks::Green, "CLEAN", false),
6726 seen("a", Checks::Pending, "BLOCKED", false),
6727 seen("a", Checks::Pending, "BLOCKED", false),
6728 seen("a", Checks::Green, "CLEAN", false),
6729 ],
6730 vec![(false, REFUSED), (true, "")],
6731 );
6732 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6733 .await
6734 .unwrap();
6735 assert_eq!(
6736 forge.calls(),
6737 [
6738 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6739 "view"
6740 ]
6741 );
6742 assert_eq!(state.status, RunStatus::Merged);
6743 }
6744
6745 #[tokio::test]
6746 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6747 let mut state = landing_state();
6748 let forge = Scripted::new(
6749 vec![
6750 seen("a", Checks::Green, "CLEAN", false),
6751 seen("a", Checks::Green, "BLOCKED", false),
6752 ],
6753 vec![(false, REFUSED), (false, REFUSED)],
6754 );
6755 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6756 .await
6757 .unwrap();
6758 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6760 let why = state.merge.as_ref().unwrap().detail.clone();
6761 assert!(
6762 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6763 "{why}"
6764 );
6765 assert_eq!(state.status, RunStatus::Blocked);
6766 }
6767
6768 #[test]
6769 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6770 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6771 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6772 assert_eq!(bound_head("old", "old", Some("new")), None);
6774 assert_eq!(bound_head("new", "old", Some("new")), None);
6776 assert_eq!(bound_head("new", "old", None), None);
6777 assert_eq!(bound_head("", "", None), None);
6779 assert_eq!(bound_head("", "", Some("new")), None);
6780 assert_eq!(bound_head("abc", "", None), None);
6781 }
6782
6783 fn view_json(head: &str) -> String {
6784 format!(
6785 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6786 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6787 "reviews":[],"comments":[]}}"#
6788 )
6789 }
6790
6791 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6792 format!(
6793 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6794 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6795 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6796 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6797 )
6798 }
6799
6800 #[test]
6801 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6802 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6805 assert_eq!(s.rollup_head, "old");
6806 assert_eq!(s.pr.checks, Checks::Red);
6807 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6808 assert_eq!(s.failing_urls.len(), 1);
6809 }
6810
6811 #[test]
6812 fn checks_come_from_the_node_not_the_view() {
6813 let view = view_json("new").replace(
6814 r#""reviews""#,
6815 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6816 );
6817 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6818 assert_eq!(s.pr.checks, Checks::Green);
6819 assert!(s.pr.failing.is_empty());
6820 assert_eq!(
6821 bound_head(&s.head, &s.rollup_head, Some("new")),
6822 Some("new")
6823 );
6824 }
6825
6826 #[test]
6827 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6828 for node in [
6829 None,
6830 Some("not json".to_owned()),
6831 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6832 Some(node_json("new", "SUCCESS", true)),
6833 ] {
6834 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6835 assert!(s.rollup_head.is_empty());
6836 assert_eq!(s.pr.checks, Checks::Unknown);
6837 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6838 }
6839 }
6840
6841 #[test]
6842 fn the_merge_command_is_pinned_to_the_observed_head() {
6843 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6844 let at = argv
6845 .iter()
6846 .position(|a| a == "--match-head-commit")
6847 .unwrap();
6848 assert_eq!(argv[at + 1], "deadbeef");
6849 }
6850
6851 #[tokio::test]
6852 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6853 let mut state = landing_state();
6854 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6857 stale.rollup_head = "old".to_owned();
6858 let forge = Scripted::new(
6859 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6860 vec![],
6861 );
6862 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6863 head: "new".to_owned(),
6864 });
6865 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6866 .await
6867 .unwrap();
6868 assert!(!forge.calls().contains(&"merge"));
6869 assert_eq!(state.status, RunStatus::Blocked);
6870 let why = state.merge.as_ref().unwrap().detail.clone();
6871 assert!(why.contains("new") && why.contains("old"), "{why}");
6872 }
6873
6874 #[test]
6875 fn a_refusal_read_against_another_commits_checks_is_pending() {
6876 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6877 after.rollup_head = "old".to_owned();
6878 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6879 }
6880
6881 #[tokio::test]
6882 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6883 let mut state = landing_state();
6884 let forge = Scripted::new(
6885 vec![seen("a", Checks::Red, "CLEAN", false)],
6886 vec![(true, "")],
6887 );
6888 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6889 .await
6890 .unwrap();
6891 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6892 assert_eq!(state.status, RunStatus::Merged);
6893 }
6894
6895 #[tokio::test]
6896 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6897 let mut state = landing_state();
6898 let forge = Scripted::new(
6899 vec![
6900 seen("a", Checks::Green, "CLEAN", false),
6901 seen("a", Checks::Green, "CLEAN", false),
6902 seen("b", Checks::Green, "BLOCKED", false),
6904 seen("b", Checks::Green, "CLEAN", false),
6905 ],
6906 vec![(false, REFUSED), (true, "")],
6907 );
6908 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6909 .await
6910 .unwrap();
6911 assert_eq!(
6912 forge.calls(),
6913 [
6914 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6915 ]
6916 );
6917 assert_eq!(state.status, RunStatus::Merged);
6918 }
6919
6920 fn merged_view(head: &str) -> Seen {
6921 let mut m = seen(head, Checks::Green, "CLEAN", false);
6922 m.pr.state = PrLifecycle::Merged;
6923 m
6924 }
6925
6926 fn has(argv: &[String], flag: &str) -> bool {
6927 argv.iter().any(|a| a == flag)
6928 }
6929
6930 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6931 let at = argv.iter().position(|a| a == flag)?;
6932 argv.get(at + 1).map(String::as_str)
6933 }
6934
6935 const URL: &str = "https://github.com/o/r/pull/42";
6936
6937 #[tokio::test]
6938 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6939 let mut state = landing_state();
6940 let forge = Scripted::new(
6941 vec![
6942 seen("abc", Checks::Green, "CLEAN", false),
6943 seen("abc", Checks::Green, "CLEAN", false),
6944 ],
6945 vec![(true, "")],
6946 );
6947 land_with(&mut state, URL, &forge).await.unwrap();
6948 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6949 let argv = &forge.argvs()[0];
6950 assert!(has(argv, "--squash") && has(argv, "--subject"));
6951 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6952 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6953 assert_eq!(state.status, RunStatus::Merged);
6954 assert!(state.land_armed_head.is_none());
6955 }
6956
6957 #[tokio::test]
6958 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6959 let mut state = landing_state();
6960 state.land_armed_head = Some("a".to_owned());
6961 let forge = Scripted::new(
6962 vec![seen("a", Checks::Green, "CLEAN", false)],
6963 vec![(true, ""), (true, "")],
6964 );
6965 land_with(&mut state, URL, &forge).await.unwrap();
6966 let argvs = forge.argvs();
6967 assert!(has(&argvs[0], "--disable-auto"));
6968 assert!(!has(&argvs[1], "--auto"));
6969 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6970 assert_eq!(state.status, RunStatus::Merged);
6971 assert!(state.land_armed_head.is_none());
6972 }
6973
6974 #[tokio::test]
6975 async fn an_approval_never_carries_over_to_a_new_head() {
6976 crate::run::pin_test_home();
6977 let mut state = run_state();
6978 state.config.graph.land_approval = true;
6979 let pr = green_pr();
6980 let store = ask::Questions::open();
6981
6982 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6983 .await
6984 .unwrap();
6985 let mut q = store
6986 .list()
6987 .into_iter()
6988 .find(|q| q.run == state.id)
6989 .unwrap();
6990 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6991 store.put(&mut q).unwrap();
6992 assert_eq!(
6993 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6994 .await
6995 .unwrap(),
6996 ApprovalGate::Approved,
6997 "the same head keeps its approval"
6998 );
6999
7000 assert_eq!(
7002 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7003 .await
7004 .unwrap(),
7005 ApprovalGate::Pending
7006 );
7007 let all: Vec<_> = store
7008 .list()
7009 .into_iter()
7010 .filter(|q| q.run == state.id)
7011 .collect();
7012 assert_eq!(all.len(), 2);
7013
7014 state.land_approval = None;
7016 assert_eq!(
7017 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7018 .await
7019 .unwrap(),
7020 ApprovalGate::Pending
7021 );
7022 let open = store
7023 .list()
7024 .into_iter()
7025 .filter(|q| q.run == state.id && q.status.open())
7026 .count();
7027 assert_eq!(open, 1, "the superseded question was retired");
7028 }
7029
7030 #[tokio::test]
7031 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7032 let mut state = landing_state();
7033 let forge = Scripted::new(
7034 vec![
7035 seen("a", Checks::Green, "CLEAN", false),
7036 seen("b", Checks::Green, "CLEAN", false),
7038 ],
7039 vec![(true, "")],
7040 );
7041 land_with(&mut state, URL, &forge).await.unwrap();
7042 let argvs = forge.argvs();
7043 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7044 assert!(!has(&argvs[0], "--auto"));
7045 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7046 assert_eq!(state.status, RunStatus::Merged);
7047 }
7048
7049 fn passing(label: &str) -> CheckInfo {
7050 CheckInfo {
7051 label: label.to_owned(),
7052 verdict: Verdict::Pass,
7053 required: Some(false),
7054 }
7055 }
7056
7057 fn names(xs: &[&str]) -> BTreeSet<String> {
7058 xs.iter().map(|x| (*x).to_owned()).collect()
7059 }
7060
7061 #[test]
7062 fn a_required_check_the_rollup_never_listed_is_named() {
7063 let req = names(&["build"]);
7064 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7065 assert!(why.contains("never reported: build"), "{why}");
7066 assert!(!why.contains("probably waiting for a review"), "{why}");
7067 }
7068
7069 #[test]
7070 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7071 let why = waiting_on("BLOCKED", &[passing("review")], None);
7072 assert!(why.contains("could not be read"), "{why}");
7073 assert!(!why.contains("probably waiting for a review"), "{why}");
7074 }
7075
7076 #[test]
7077 fn all_required_reported_keeps_the_review_guess() {
7078 let req = names(&["build"]);
7079 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7080 assert!(why.contains("probably waiting for a review"), "{why}");
7081 assert!(!why.contains("never reported"), "{why}");
7082 }
7083
7084 #[test]
7085 fn required_names_match_the_rollup_ignoring_case_only() {
7086 let req = names(&["Build"]);
7087 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7088 assert!(!why.contains("never reported"), "{why}");
7089 }
7090
7091 #[test]
7092 fn required_contexts_are_read_from_protection_and_rulesets() {
7093 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7094 assert_eq!(
7095 parse_classic_required(classic),
7096 Some(names(&["build", "lint"]))
7097 );
7098 let rules = r#"[{"type":"pull_request","parameters":{}},
7099 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7100 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7101 assert_eq!(parse_ruleset_required("nope"), None);
7102 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7103 }
7104
7105 #[test]
7106 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7107 let shown = BTreeSet::new();
7108 let ok = seen("a", Checks::Green, "CLEAN", false);
7109 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7110 assert!(guard(Some(&ok)));
7111 assert!(!guard(None));
7112 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7113 let mut stale = ok.clone();
7114 stale.rollup_head = "old".to_owned();
7115 assert!(!guard(Some(&stale)));
7116 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7117 assert!(!guard(Some(&merged_view("a"))));
7118 }
7119
7120 #[test]
7121 fn the_rollup_node_carries_whether_each_check_is_required() {
7122 let node = node_json("new", "SUCCESS", false)
7123 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7124 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7125 assert_eq!(s.contexts.len(), 1);
7126 assert_eq!(s.contexts[0].required, Some(true));
7127 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7128 assert_eq!(s.contexts[0].required, None);
7129 }
7130
7131 #[tokio::test]
7132 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7133 let mut state = landing_state();
7134 state.land_armed_head = Some("a".to_owned());
7135 let forge = Scripted::new(
7136 vec![seen("a", Checks::Green, "CLEAN", true)],
7137 vec![(false, "disable exploded")],
7138 );
7139 land_with(&mut state, URL, &forge).await.unwrap();
7140 assert!(!forge.calls().contains(&"fix"));
7141 assert_eq!(state.status, RunStatus::Blocked);
7142 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7143 let why = state.merge.as_ref().unwrap().detail.clone();
7144 assert!(why.contains("disable exploded"), "{why}");
7145 }
7146}