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::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58 ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61pub const POLL: Duration = Duration::from_secs(30);
67
68pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88const LOG_TAIL: usize = 4_000;
91
92const MAX_LOGS: usize = 3;
95
96pub const MARKER: &str = "<!-- magi:land -->";
102
103const NOT_A_REVIEW: [&str; 3] = [
112 "skip review by coderabbit.ai",
113 "summarize by coderabbit.ai",
114 "<!-- tips_start -->",
115];
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120 Open,
122 Merged,
124 Closed,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131 Pending,
133 Green,
136 Red,
138 Unknown,
140}
141
142impl PrLifecycle {
143 pub fn as_str(self) -> &'static str {
145 match self {
146 Self::Open => "open",
147 Self::Merged => "merged",
148 Self::Closed => "closed",
149 }
150 }
151}
152
153impl Checks {
154 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::Pending => "pending",
158 Self::Green => "green",
159 Self::Red => "red",
160 Self::Unknown => "unknown",
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168 pub author: String,
170 pub path: Option<String>,
172 pub line: Option<u64>,
174 pub body: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181 pub url: String,
183 pub number: u64,
185 pub state: PrLifecycle,
187 pub checks: Checks,
189 pub failing: Vec<String>,
191 pub review_comments: Vec<ReviewComment>,
193 pub blocking: Blocking,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211 No,
213 Yes,
215 Conflict,
217 Unsaid,
221}
222
223impl Blocking {
224 fn of(raw: &str) -> Self {
226 match raw.to_ascii_uppercase().as_str() {
227 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230 "DIRTY" => Self::Conflict,
231 "" | "UNKNOWN" => Self::Unsaid,
232 _ => Self::Yes,
234 }
235 }
236
237 #[must_use]
239 pub fn stops_a_merge(self) -> bool {
240 !matches!(self, Self::No)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247 Wait,
249 Rebase,
257 Fix {
259 reason: String,
261 },
262 Merge,
264 Done {
266 merged: bool,
268 },
269 GiveUp {
271 reason: String,
273 },
274}
275
276pub(crate) fn merged_after_all(
298 argv: &[String],
299 stderr: &str,
300 after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302 if after? != PrLifecycle::Merged {
303 return None;
304 }
305 Some(MergeOutcome {
306 mode: MergeMode::Pr,
307 ok: true,
308 detail: format!(
309 "gh {} (the command reported `{}`, but the pull request is merged)",
310 argv.join(" "),
311 stderr.trim()
312 ),
313 empty: false,
314 })
315}
316
317pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337 decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343 Expected,
346 Absent,
351}
352
353pub fn decide_with(
358 pr: &PrState,
359 round: usize,
360 budget: usize,
361 waited: Duration,
362 ci: CiExpectation,
363) -> Step {
364 match pr.state {
365 PrLifecycle::Merged => return Step::Done { merged: true },
366 PrLifecycle::Closed => return Step::Done { merged: false },
367 PrLifecycle::Open => {}
368 }
369
370 if pr.blocking == Blocking::Conflict {
373 return Step::Rebase;
374 }
375
376 if ci == CiExpectation::Absent {
377 return Step::Merge;
378 }
379
380 let spent = round >= budget;
381 match pr.checks {
382 Checks::Pending => Step::Wait,
383 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384 Checks::Unknown => Step::GiveUp {
385 reason: format!(
386 "no check status is readable on the pull request after {} minute(s); \
387 refusing to merge on a guess",
388 CHECKS_GRACE.as_secs() / 60
389 ),
390 },
391 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398 Checks::Red => {
399 let what = format!(
400 "{} check(s) failing: {}",
401 pr.failing.len(),
402 pr.failing.join(", ")
403 );
404 if spent {
405 Step::GiveUp {
406 reason: format!("{what} — still red after {budget} fix round(s)"),
407 }
408 } else {
409 Step::Fix { reason: what }
410 }
411 }
412 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413 Checks::Green => {
414 let what = format!(
415 "checks are green but {} review comment(s) are unresolved: {}",
416 pr.review_comments.len(),
417 authors(&pr.review_comments)
418 );
419 if spent {
420 Step::GiveUp {
421 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422 }
423 } else {
424 Step::Fix { reason: what }
425 }
426 }
427 }
428}
429
430fn authors(comments: &[ReviewComment]) -> String {
432 let mut seen: Vec<&str> = Vec::new();
433 for c in comments {
434 if !seen.contains(&c.author.as_str()) {
435 seen.push(&c.author);
436 }
437 }
438 seen.join(", ")
439}
440
441pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445 vec![
446 "pr".to_owned(),
447 "merge".to_owned(),
448 number.to_string(),
449 "--squash".to_owned(),
450 "--delete-branch".to_owned(),
451 "--subject".to_owned(),
452 subject.to_owned(),
453 ]
454}
455
456pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462 let mut argv = merge_argv(number, subject);
463 argv.push("--match-head-commit".to_owned());
464 argv.push(head.to_owned());
465 argv
466}
467
468pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473 ["pr", "merge", &number.to_string(), "--disable-auto"]
474 .map(str::to_owned)
475 .to_vec()
476}
477
478fn direct_merge_is_safe(
484 fresh: Option<&Seen>,
485 approved_head: &str,
486 shown: &BTreeSet<String>,
487 round: usize,
488 budget: usize,
489 waited: Duration,
490) -> bool {
491 let Some(fresh) = fresh else {
492 return false;
493 };
494 if fresh.pr.state != PrLifecycle::Open {
495 return false;
496 }
497 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498 return false;
499 };
500 if !bound.eq_ignore_ascii_case(approved_head) {
501 return false;
502 }
503 let mut pr = fresh.pr.clone();
504 pr.review_comments.retain(|c| !shown.contains(&c.body));
505 decide(&pr, round, budget, waited) == Step::Merge
506}
507
508fn waiting_on(
515 merge_state: &str,
516 contexts: &[CheckInfo],
517 required_set: Option<&BTreeSet<String>>,
518) -> String {
519 let state = if merge_state.is_empty() {
520 "unknown"
521 } else {
522 merge_state
523 };
524 let tag = |c: &CheckInfo| match c.verdict {
525 Verdict::Fail => "failed",
526 _ => "pending",
527 };
528 let unsettled: Vec<&CheckInfo> = contexts
529 .iter()
530 .filter(|c| c.verdict != Verdict::Pass)
531 .collect();
532 let required: Vec<String> = unsettled
533 .iter()
534 .filter(|c| c.required == Some(true))
535 .map(|c| format!("{} ({})", c.label, tag(c)))
536 .collect();
537 let unknown: Vec<String> = unsettled
538 .iter()
539 .filter(|c| c.required.is_none())
540 .map(|c| format!("{} ({})", c.label, tag(c)))
541 .collect();
542 let never: Vec<&str> = required_set
546 .map(|set| {
547 set.iter()
548 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549 .map(String::as_str)
550 .collect()
551 })
552 .unwrap_or_default();
553 let mut out = format!("merge state: {state}");
554 if !never.is_empty() {
555 let _ = write!(
556 out,
557 "; required checks never reported: {}",
558 never.join(", ")
559 );
560 }
561 if !required.is_empty() {
562 let _ = write!(
563 out,
564 "; required checks not passing: {}",
565 required.join(", ")
566 );
567 }
568 if !unknown.is_empty() {
569 let _ = write!(
570 out,
571 "; whether these are required could not be read, so they may be: {}",
572 unknown.join(", ")
573 );
574 }
575 if required.is_empty() && unknown.is_empty() && never.is_empty() {
576 if required_set.is_some() {
577 out.push_str(
578 "; no required check is pending, failing or unreported, so GitHub is probably \
579 waiting for a review or another branch rule",
580 );
581 } else {
582 out.push_str(
583 "; the required check list could not be read, so a required check that was \
584 never reported cannot be ruled out",
585 );
586 }
587 }
588 out
589}
590
591pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598 let title = pr_title.trim();
599 if !title.is_empty() && !title.starts_with("magi: candidate") {
600 return title.to_owned();
601 }
602 let first = instruction
603 .lines()
604 .map(str::trim)
605 .find(|l| !l.is_empty())
606 .unwrap_or("magi: land the winning candidate");
607 first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610pub const APPROVE: &str = "merge";
612
613pub const HOLD: &str = "hold";
615
616pub const REPAIR: &str = "fix findings and re-review";
618
619pub fn repair_available(state: &RunState) -> bool {
622 state.reviews.last().is_some_and(|r| {
623 state.open_findings().iter().any(|f| f.severity.blocks())
624 || r.final_votes()
625 .iter()
626 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627 })
628}
629
630pub fn approval_choices(state: &RunState) -> Vec<String> {
632 let mut choices = vec![
633 APPROVE.to_owned(),
634 HOLD.to_owned(),
635 REPAIR.to_owned(),
636 crate::merge_decision::WITHDRAW.to_owned(),
637 crate::merge_decision::DISMISS.to_owned(),
638 ];
639 choices.extend(
640 state
641 .config
642 .agents
643 .iter()
644 .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
645 );
646 choices
647}
648
649pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
652 if q.node != APPROVAL_NODE
653 || !state
654 .land_approval
655 .as_ref()
656 .is_some_and(|a| a.question == q.id)
657 {
658 return false;
659 }
660 let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
661 if q.status.open() {
662 q.actions.remove(HOLD);
665 for choice in approval_choices(state) {
666 if !q.choices.contains(&choice) {
667 q.choices.push(choice);
668 }
669 }
670 if q.merge_target.is_none() {
671 q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
672 pr: pr.url.clone(),
673 head: state.land_approval.as_ref().unwrap().head.clone(),
674 language: state.config.graph.language.clone(),
675 });
676 }
677 }
678 for choice in q.choices.clone() {
679 if (q.status.open()
680 && crate::merge_decision::stops(&choice)
681 && choice != HOLD
682 && choice != crate::merge_decision::DISMISS)
683 || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
684 {
685 q.actions.insert(
686 choice,
687 ask::ChoiceAction::Resume {
688 run: state.id.clone(),
689 },
690 );
691 }
692 }
693 before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
694}
695
696pub fn unhedged(message: &str, quote: &str) -> bool {
704 let quote = quote.trim();
705 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
706}
707
708fn hedged(text: &str) -> bool {
712 const WORDS: &[&str] = &[
713 "maybe",
714 "probably",
715 "perhaps",
716 "might",
717 "if",
718 "unless",
719 "not",
720 "no",
721 "nope",
722 "stop",
723 "dont",
724 "abort",
725 "revert",
726 "undo",
727 "never",
728 "wait",
729 "hold",
730 "cancel",
731 "but",
732 "think",
733 "guess",
734 "suppose",
735 "unsure",
736 "yet",
737 "except",
738 "only",
739 "cannot",
740 "should",
741 "once",
742 "provided",
743 "providing",
744 "when",
745 "whenever",
746 "after",
747 "until",
748 "before",
749 "assuming",
750 "given",
751 "while",
752 "whether",
753 "depending",
754 "pending",
755 ];
756 const JA: &[&str] = &[
757 "かも",
758 "たぶん",
759 "多分",
760 "なら",
761 "たら",
762 "ちょっと待",
763 "しないで",
764 "しない",
765 "保留",
766 "まだ",
767 "ただし",
768 "やめ",
769 "いや",
770 "止め",
771 "だめ",
772 "ダメ",
773 "じゃない",
774 "ではない",
775 "ですか",
776 "かな",
777 "でしょう",
778 "思う",
779 "でも",
780 "けど",
781 "ただ",
782 "次第",
783 "場合",
784 "限り",
785 "条件",
786 "とき",
787 "まで",
788 "後で",
789 ];
790 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
791 return true;
792 }
793 text.to_lowercase()
794 .replace('\u{2019}', "'")
795 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
796 .filter(|w| !w.is_empty())
797 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
798}
799
800fn retracts(message: &str) -> bool {
803 let lower = message.to_lowercase();
804 [
805 "やっぱ",
806 "待って",
807 "撤回",
808 "never mind",
809 "actually",
810 "on second thought",
811 ]
812 .iter()
813 .any(|w| lower.contains(w))
814 || lower
815 .split(|c: char| !c.is_ascii_alphabetic())
816 .any(|w| w == "wait")
817}
818
819pub const APPROVAL_NODE: &str = "land-approval";
825
826pub const DIFF_MAX_LINES: usize = 400;
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum Approval {
838 Merge,
840 Repair,
842 Hold,
844}
845
846pub fn approval(answer: Option<&str>) -> Approval {
854 match answer {
855 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
856 Some(a) if a.trim() == REPAIR => Approval::Repair,
857 _ => Approval::Hold,
858 }
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863enum ApprovalGate {
864 Approved,
866 Repair,
868 Held,
871 Pending,
873}
874
875fn esc(s: &str) -> String {
885 let mut out = String::with_capacity(s.len());
886 for c in s.chars() {
887 match c {
888 '&' => out.push_str("&"),
889 '<' => out.push_str("<"),
890 '>' => out.push_str(">"),
891 '"' => out.push_str("""),
892 '\'' => out.push_str("'"),
893 _ => out.push(c),
894 }
895 }
896 out
897}
898
899#[derive(Debug, Clone, PartialEq, Eq)]
901struct StatRow {
902 path: String,
903 added: Option<u64>,
905 removed: Option<u64>,
906}
907
908impl StatRow {
909 fn churn(&self) -> u64 {
912 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
913 }
914}
915
916fn parse_numstat(numstat: &str) -> Vec<StatRow> {
922 let mut rows: Vec<StatRow> = numstat
923 .lines()
924 .filter_map(|line| {
925 let mut parts = line.splitn(3, '\t');
926 let added = parts.next()?.trim();
927 let removed = parts.next()?.trim();
928 let path = parts.next()?.trim();
929 if path.is_empty() {
930 return None;
931 }
932 Some(StatRow {
933 path: path.to_owned(),
934 added: added.parse().ok(),
935 removed: removed.parse().ok(),
936 })
937 })
938 .collect();
939 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
942 rows
943}
944
945fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
954 if line.starts_with("+++") || line.starts_with("---") {
955 (" ", "color:#57606a;font-weight:600", line)
956 } else if let Some(body) = line.strip_prefix('+') {
957 ("+", "background:#e6ffec;color:#0a3622", body)
958 } else if let Some(body) = line.strip_prefix('-') {
959 ("-", "background:#ffebe9;color:#5c1a17", body)
960 } else if line.starts_with("@@") {
961 ("~", "background:#eef2ff;color:#3730a3", line)
962 } else if let Some(body) = line.strip_prefix(' ') {
963 (" ", "", body)
964 } else {
965 (" ", "color:#57606a;font-weight:600", line)
966 }
967}
968
969struct Words {
978 html_lang: &'static str,
979 task: &'static str,
980 what_changed: &'static str,
981 review_verdict: &'static str,
982 reviewer: &'static str,
983 reviewer_no_answer: &'static str,
984 checks: &'static str,
985 nothing_failing: &'static str,
986 files_changed: &'static str,
987 commits: &'static str,
988 no_commits: &'static str,
989 comments: &'static str,
990 no_comments: &'static str,
991 diff: &'static str,
992 truncated: &'static str,
993 lands_as: &'static str,
994}
995
996const EN: Words = Words {
997 html_lang: "en",
998 task: "Task",
999 what_changed: "What changed",
1000 review_verdict: "Review verdict",
1001 reviewer: "Reviewer",
1002 reviewer_no_answer: "produced no answer",
1003 checks: "Checks",
1004 nothing_failing: "Nothing failing.",
1005 files_changed: "file(s) changed",
1006 commits: "Commits being squashed",
1007 no_commits: "No commit subjects could be read from the branch.",
1008 comments: "Review comments",
1009 no_comments: "Nothing outstanding at this observation.",
1010 diff: "Diff",
1011 truncated: "Truncated",
1012 lands_as: "They land as one commit titled",
1013};
1014
1015const JA: Words = Words {
1016 html_lang: "ja",
1017 task: "タスク",
1018 what_changed: "変更内容",
1019 review_verdict: "レビューの結論",
1020 reviewer: "レビュアー",
1021 reviewer_no_answer: "回答なし",
1022 checks: "チェック",
1023 nothing_failing: "失敗しているものはありません。",
1024 files_changed: "ファイル変更",
1025 commits: "squash されるコミット",
1026 no_commits: "ブランチからコミット件名を読めませんでした。",
1027 comments: "レビューコメント",
1028 no_comments: "この時点で未対応のものはありません。",
1029 diff: "差分",
1030 truncated: "省略",
1031 lands_as: "これらは次の件名の1コミットとして入ります:",
1032};
1033
1034impl Words {
1035 fn lands_as_tail(&self) -> &'static str {
1039 if self.html_lang == "ja" {
1040 "。この件名も承認の対象です。"
1041 } else {
1042 ", which you are approving too."
1043 }
1044 }
1045
1046 fn approval_summary(&self, number: u64, subject: &str) -> String {
1048 if self.html_lang == "ja" {
1049 format!("プルリクエスト #{number} をマージ: {subject}")
1050 } else {
1051 format!("merge pull request #{number}: {subject}")
1052 }
1053 }
1054
1055 fn approval_detail(
1057 &self,
1058 url: &str,
1059 base: &str,
1060 subject: &str,
1061 contested: Option<&ContestedHandoff>,
1062 ) -> String {
1063 let body = if self.html_lang == "ja" {
1064 format!(
1065 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1066 できる状態です。差分の要約・パッチ・squash されるコミットは\
1067 下のパネルにあります。"
1068 )
1069 } else {
1070 format!(
1071 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1072 The panel holds the diffstat, the patch and the commits being squashed."
1073 )
1074 };
1075 match contested {
1076 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1077 None => body,
1078 }
1079 }
1080
1081 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1084 const SHOWN: usize = 5;
1085 const TITLE_CHARS: usize = 100;
1086 let ja = self.html_lang == "ja";
1087 let mut out = if ja {
1088 format!(
1089 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1090 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1091 )
1092 } else {
1093 format!(
1094 "{url} is held for approval although merge approvals are off: the \
1095 review ended with blocking findings still open and a reviewer \
1096 voting reject.\n"
1097 )
1098 };
1099 for f in c.findings.iter().take(SHOWN) {
1100 let at = match (&f.file, f.line) {
1101 (Some(file), Some(line)) => format!("{file}:{line}"),
1102 (Some(file), None) => file.clone(),
1103 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1104 };
1105 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1106 let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1107 }
1108 if c.findings.len() > SHOWN {
1109 let more = c.findings.len() - SHOWN;
1110 let _ = writeln!(
1111 out,
1112 "{}",
1113 if ja {
1114 format!("- ほか {more} 件")
1115 } else {
1116 format!("- and {more} more")
1117 }
1118 );
1119 }
1120 let seats: Vec<String> = c
1121 .rejecters
1122 .iter()
1123 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1124 .collect();
1125 let _ = write!(
1126 out,
1127 "{} {}",
1128 if ja {
1129 "却下したレビュアー:"
1130 } else {
1131 "Rejected by reviewer:"
1132 },
1133 seats.join(", ")
1134 );
1135 out
1136 }
1137
1138 fn truncated_note(
1140 &self,
1141 omitted: usize,
1142 total: usize,
1143 shown: usize,
1144 where_: &str,
1145 base: &str,
1146 head: &str,
1147 ) -> String {
1148 if self.html_lang == "ja" {
1149 format!(
1150 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1151 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1152 プルリクエストにあります。"
1153 )
1154 } else {
1155 format!(
1156 "{omitted} of {total} diff lines omitted after the first {shown}. \
1157 The whole patch is in <code>{where_}</code> \
1158 (<code>git diff {base}...{head}</code>) and on the pull request."
1159 )
1160 }
1161 }
1162}
1163
1164fn words(language: &str) -> &'static Words {
1167 if crate::lang::is_japanese(language) {
1168 &JA
1169 } else {
1170 &EN
1171 }
1172}
1173
1174pub fn approval_panel(
1186 state: &RunState,
1187 pr: &PrState,
1188 diffstat: &str,
1189 diff: &str,
1190 commits: &[String],
1191 subject: &str,
1192) -> String {
1193 let rows = parse_numstat(diffstat);
1194 let w = words(&state.config.graph.language);
1195 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1196
1197 let _ = writeln!(
1198 h,
1199 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1200 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1201 w.html_lang
1202 );
1203 let _ = writeln!(
1204 h,
1205 "<title>merge #{} — {}</title>\n</head>",
1206 pr.number,
1207 esc(subject)
1208 );
1209 h.push_str(
1210 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1211 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1212 word-break:break-word\">\n",
1213 );
1214
1215 let _ = writeln!(
1217 h,
1218 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1219 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1220 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1221 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1222 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1223 pr.number,
1224 esc(&state.base_branch),
1225 esc(subject),
1226 esc(&state.id),
1227 esc(&pr.url),
1228 esc(&pr.url),
1229 );
1230
1231 let _ = writeln!(
1234 h,
1235 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1236 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1237 w.task,
1238 esc(&state.instruction)
1239 );
1240
1241 if let Some(summary) = state
1243 .winner()
1244 .map(|c| c.summary.as_str())
1245 .filter(|s| !s.is_empty())
1246 {
1247 let _ = writeln!(
1248 h,
1249 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1250 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1251 w.what_changed,
1252 esc(summary)
1253 );
1254 }
1255
1256 if let Some(round) = state.reviews.last() {
1259 let _ = writeln!(
1260 h,
1261 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1262 w.review_verdict
1263 );
1264 for r in &round.reviews {
1265 let body = match &r.failed {
1272 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1273 None => esc(&r.summary),
1274 };
1275 let _ = writeln!(
1276 h,
1277 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1278 border-radius:6px\">\
1279 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1280 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1281 w.reviewer,
1282 r.reviewer,
1283 esc(&r.agent),
1284 body,
1285 );
1286 }
1287 }
1288
1289 let _ = writeln!(
1290 h,
1291 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1292 w.checks,
1293 esc(pr.checks.as_str())
1294 );
1295 if pr.failing.is_empty() {
1296 let _ = writeln!(
1297 h,
1298 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1299 w.nothing_failing
1300 );
1301 } else {
1302 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1303 for f in &pr.failing {
1304 let _ = writeln!(h, "<li>{}</li>", esc(f));
1305 }
1306 h.push_str("</ul>\n");
1307 }
1308
1309 let _ = writeln!(
1312 h,
1313 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1314 rows.len(),
1315 w.files_changed
1316 );
1317 h.push_str(
1318 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1319 <thead><tr>\
1320 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1321 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1322 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1323 </th></tr></thead>\n<tbody>\n",
1324 );
1325 let mut total_added = 0u64;
1326 let mut total_removed = 0u64;
1327 for r in &rows {
1328 total_added += r.added.unwrap_or(0);
1329 total_removed += r.removed.unwrap_or(0);
1330 let cell = |n: Option<u64>| match n {
1331 Some(n) => n.to_string(),
1332 None => "bin".to_owned(),
1333 };
1334 let _ = writeln!(
1335 h,
1336 "<tr>\
1337 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1338 font-family:ui-monospace,monospace\">{}</td>\
1339 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1340 color:#0a3622\">{}</td>\
1341 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1342 color:#5c1a17\">{}</td></tr>",
1343 esc(&r.path),
1344 cell(r.added),
1345 cell(r.removed),
1346 );
1347 }
1348 let _ = writeln!(
1349 h,
1350 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1351 <td style=\"padding:4px 2px\">total</td>\
1352 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1353 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1354 </tr></tfoot>\n</table>"
1355 );
1356
1357 let _ = writeln!(
1359 h,
1360 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1361 w.commits
1362 );
1363 if commits.is_empty() {
1364 h.push_str(&format!(
1365 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1366 w.no_commits
1367 ));
1368 } else {
1369 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1370 for c in commits {
1371 let _ = writeln!(h, "<li>{}</li>", esc(c));
1372 }
1373 h.push_str("</ol>\n");
1374 }
1375 let _ = writeln!(
1376 h,
1377 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1378 w.lands_as,
1379 esc(subject),
1380 w.lands_as_tail()
1381 );
1382
1383 let _ = writeln!(
1385 h,
1386 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1387 w.comments
1388 );
1389 if pr.review_comments.is_empty() {
1390 h.push_str(&format!(
1391 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1392 w.no_comments
1393 ));
1394 } else {
1395 for c in &pr.review_comments {
1396 let anchor = match (&c.path, c.line) {
1397 (Some(p), Some(l)) => format!("{p}:{l}"),
1398 (Some(p), None) => p.clone(),
1399 _ => "pull request thread".to_owned(),
1400 };
1401 let _ = writeln!(
1402 h,
1403 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1404 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1405 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1406 esc(&c.author),
1407 esc(&anchor),
1408 esc(&tail(&c.body, 800)),
1409 );
1410 }
1411 }
1412
1413 let total = diff.lines().count();
1415 let shown = total.min(DIFF_MAX_LINES);
1416 let _ = writeln!(
1417 h,
1418 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1419 w.diff
1420 );
1421 h.push_str(
1422 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1423 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1424 );
1425 for line in diff.lines().take(shown) {
1426 let (gutter, style, body) = diff_row(line);
1427 let _ = writeln!(
1428 h,
1429 "<div style=\"display:flex;{style}\">\
1430 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1431 border-right:1px solid #d0d7de\">{gutter}</span>\
1432 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1433 esc(body),
1434 );
1435 }
1436 h.push_str("</div>\n");
1437 if total > shown {
1438 let omitted = total - shown;
1439 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1440 let where_ = state.winner().map_or_else(
1441 || state.repo.display().to_string(),
1442 |w| w.worktree.display().to_string(),
1443 );
1444 let _ = writeln!(
1445 h,
1446 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1447 font-size:13px\">{}: {}</p>",
1448 w.truncated,
1449 w.truncated_note(
1450 omitted,
1451 total,
1452 shown,
1453 &esc(&where_),
1454 &esc(&state.base_branch),
1455 &esc(head),
1456 ),
1457 );
1458 }
1459
1460 h.push_str("</body>\n</html>\n");
1461 h
1462}
1463
1464fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1468 if state.config.graph.hold_contested_merge {
1469 state.contested_handoff.clone()
1470 } else {
1471 None
1472 }
1473}
1474
1475pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1484 let mut s = format!(
1485 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1486 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1487 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1488 request open. Silence is a hold: the owner not answering never merges. Only \
1489 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1490 own words, merges. Whether their wording is a clear, unconditional instruction \
1491 is your judgement alone: if it is doubtful, conditional, retracted or a \
1492 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1493 silence are a hold.\n\n\
1494 This brief is a snapshot from when you were attached: check `magi show {run}` \
1495 and `gh pr view` (read-only) before telling the owner anything current. \
1496 You run with permission to write the question record, and what keeps you \
1497 from touching anything else is this brief and your instructions - so do \
1498 not change files, branches or the pull request.",
1499 run = q.run
1500 );
1501 if let Some(target) = &q.merge_target {
1502 s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1503 }
1504 if q.choices.iter().any(|c| c == REPAIR) {
1505 s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1506 }
1507 let Some(state) = state else {
1508 s.push_str(
1509 "\n\nThe run's record could not be read, so the pull request, the panel \
1510 summary and any contested findings are not known to you beyond the \
1511 question's own text. Say so to the owner rather than guessing.",
1512 );
1513 return s;
1514 };
1515 if let Some(pr) = &state.pr {
1516 s.push_str(&format!(
1517 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1518 pr.number, pr.url, pr.state
1519 ));
1520 }
1521 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1522 if let Some(w) = state.winner() {
1523 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1524 }
1525 match contested_to_ask(state) {
1526 Some(c) => {
1527 s.push_str(
1528 "\n\nThis question was filed although merge approvals are off, because \
1529 the review hand-off is contested. Open findings:",
1530 );
1531 for f in &c.findings {
1532 let at = match (&f.file, f.line) {
1533 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1534 (Some(file), None) => format!(" ({file})"),
1535 _ => String::new(),
1536 };
1537 s.push_str(&format!("\n- [{}] {}{at}: {}", f.id, f.severity, f.title));
1538 }
1539 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1540 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1541 }
1542 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1543 }
1544 s
1545}
1546
1547fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1551 if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1552 return Ok(false);
1553 }
1554 let Some(task_id) = state
1555 .origin
1556 .as_ref()
1557 .and_then(|origin| origin.task.as_ref())
1558 else {
1559 return Ok(false);
1560 };
1561 let task = crate::queue::Queue::open()
1562 .get(task_id)
1563 .context("cannot verify operator decisions for the merge approval")?;
1564 Ok(task
1565 .operator_decided_at
1566 .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1567}
1568
1569async fn approval_gate(
1589 state: &mut RunState,
1590 pr: &PrState,
1591 subject: &str,
1592 contested: Option<&ContestedHandoff>,
1593 head: &str,
1594) -> Result<ApprovalGate> {
1595 let store = ask::Questions::open();
1596 let reusable = state.land_approval.as_ref().and_then(|a| {
1601 store
1602 .list()
1603 .into_iter()
1604 .find(|q| q.id == a.question)
1605 .filter(|q| {
1606 a.head.eq_ignore_ascii_case(head)
1607 || q.resolution()
1608 .as_deref()
1609 .is_some_and(crate::merge_decision::stops)
1610 })
1611 });
1612 let reusable = match reusable {
1613 Some(q) if merge_consent_superseded(state, &q)? => None,
1614 other => other,
1615 };
1616 if reusable.is_none() {
1617 for stale in store
1618 .list()
1619 .into_iter()
1620 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1621 {
1622 let why =
1623 "the pull request head or operator decision changed; asked again for approval";
1624 if let Err(e) = store.update(&stale.id, |q| {
1625 q.abandon(why);
1626 Ok(())
1627 }) {
1628 tracing::warn!("could not retire the superseded approval question: {e:#}");
1629 }
1630 }
1631 }
1632
1633 let q = match reusable {
1634 Some(mut q) => {
1635 if upgrade_repair_choice(&mut q, state) {
1636 q = store
1637 .update(&q.id, |current| {
1638 upgrade_repair_choice(current, state);
1639 Ok(())
1640 })?
1641 .0;
1642 }
1643 q
1644 }
1645 None => {
1646 let worktree = match state.winner() {
1647 Some(w) => w.worktree.clone(),
1648 None => state.repo.clone(),
1649 };
1650 let head = if head.is_empty() {
1653 state
1654 .winner()
1655 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1656 } else {
1657 head.to_owned()
1658 };
1659 let remote = &state.config.merge.remote;
1662 let tracking = format!("{remote}/{}", state.base_branch);
1663 let base = if git::rev_exists(&worktree, &tracking).await {
1664 tracking
1665 } else {
1666 String::new()
1667 };
1668 let range = format!("{base}...{head}");
1669 let numstat = if base.is_empty() {
1673 String::new()
1674 } else {
1675 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1676 .await
1677 .map(|o| o.stdout)
1678 .unwrap_or_default()
1679 };
1680 let diff = if base.is_empty() {
1681 String::new()
1682 } else {
1683 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1684 };
1685 let commits: Vec<String> = if base.is_empty() {
1686 Vec::new()
1687 } else {
1688 git::git_raw(
1689 &worktree,
1690 &[
1691 "log",
1692 "--reverse",
1693 "--format=%s",
1694 &format!("{base}..{head}"),
1695 ],
1696 )
1697 .await
1698 .map(|o| o.stdout)
1699 .unwrap_or_default()
1700 .lines()
1701 .filter(|l| !l.trim().is_empty())
1702 .map(str::to_owned)
1703 .collect()
1704 };
1705
1706 let w = words(&state.config.graph.language);
1707 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1708 let mut fresh = ask::Question::new(
1709 state.id.clone(),
1710 APPROVAL_NODE.to_owned(),
1711 "land".to_owned(),
1712 w.approval_summary(pr.number, subject),
1713 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1714 approval_choices(state),
1715 );
1716 fresh.merge_target = Some(crate::merge_decision::Target {
1717 pr: pr.url.clone(),
1718 head: head.clone(),
1719 language: state.config.graph.language.clone(),
1720 });
1721 if fresh.choices.iter().any(|c| c == REPAIR) {
1722 fresh.actions.insert(
1723 REPAIR.to_owned(),
1724 ask::ChoiceAction::Resume {
1725 run: state.id.clone(),
1726 },
1727 );
1728 fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1729 }
1730 for choice in fresh.choices.clone() {
1731 if crate::merge_decision::stops(&choice)
1732 && choice != HOLD
1733 && choice != crate::merge_decision::DISMISS
1734 {
1735 fresh.actions.insert(
1736 choice,
1737 ask::ChoiceAction::Resume {
1738 run: state.id.clone(),
1739 },
1740 );
1741 }
1742 }
1743 store
1744 .put_panel(&mut fresh, &html, &[])
1745 .context("write the merge approval panel")?;
1746 store
1747 .put(&mut fresh)
1748 .context("file the merge approval question")?;
1749 state.land_approval = Some(LandApproval {
1750 question: fresh.id.clone(),
1751 head: head.clone(),
1752 });
1753 state.event(
1754 "land",
1755 format!("asking for merge approval ({})", fresh.short()),
1756 );
1757 state.save()?;
1758 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1759 tracing::warn!(
1763 "could not notify about merge approval question {}: {e:#} - \
1764 the web UI is the only surface for it now",
1765 fresh.short()
1766 );
1767 }
1768 fresh
1769 }
1770 };
1771
1772 Ok(match q.status {
1773 ask::QuestionStatus::Open => ApprovalGate::Pending,
1774 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1778 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1782 Approval::Merge => ApprovalGate::Approved,
1783 Approval::Repair if !state.reviews.is_empty() => {
1784 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1785 state.merge_repairs.push(crate::run::MergeRepair {
1786 question: q.id.clone(),
1787 head: head.to_owned(),
1788 pr: pr.url.clone(),
1789 reviews: state.reviews.clone(),
1790 gate: state.gate.clone(),
1791 gate_head: state.gate_head.clone(),
1792 gate_fixes: state.gate_fixes.clone(),
1793 pre_gate: state.pre_gate.clone(),
1794 phase: crate::run::MergeRepairPhase::Requested,
1795 failure: None,
1796 });
1797 state.land_approval = None;
1798 state.event(
1799 "repair",
1800 "owner requested fix findings and re-review; merge is not approved",
1801 );
1802 state.save()?;
1803 }
1804 ApprovalGate::Repair
1805 }
1806 Approval::Repair | Approval::Hold => ApprovalGate::Held,
1807 },
1808 })
1809}
1810
1811fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1814 let mut failing = Vec::new();
1815 let mut pending = false;
1816 let mut unknown = false;
1817 for check in rollup {
1818 match check.verdict() {
1819 Verdict::Pass => {}
1820 Verdict::Pending => pending = true,
1821 Verdict::Fail => failing.push(check.label()),
1822 Verdict::Unknown => unknown = true,
1823 }
1824 }
1825 let checks = if rollup.is_empty() {
1826 Checks::Unknown
1827 } else if pending {
1828 Checks::Pending
1829 } else if !failing.is_empty() {
1830 Checks::Red
1831 } else if unknown {
1832 Checks::Unknown
1833 } else {
1834 Checks::Green
1835 };
1836 (checks, failing)
1837}
1838
1839pub fn parse_pr(json: &str) -> Result<PrState> {
1842 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1843 let state = match raw.state.to_ascii_uppercase().as_str() {
1844 "OPEN" => PrLifecycle::Open,
1845 "MERGED" => PrLifecycle::Merged,
1846 "CLOSED" => PrLifecycle::Closed,
1847 other => bail!("unknown pull request state `{other}`"),
1848 };
1849
1850 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1851
1852 let mut review_comments = Vec::new();
1853 for r in raw.reviews {
1854 push_if_outstanding(
1855 &mut review_comments,
1856 ReviewComment {
1857 author: r.author.login,
1858 path: None,
1859 line: None,
1860 body: r.body,
1861 },
1862 );
1863 }
1864 for c in raw.comments {
1865 push_if_outstanding(
1866 &mut review_comments,
1867 ReviewComment {
1868 author: c.author.login,
1869 path: None,
1870 line: None,
1871 body: c.body,
1872 },
1873 );
1874 }
1875
1876 Ok(PrState {
1877 url: raw.url,
1878 number: raw.number,
1879 state,
1880 checks,
1881 failing,
1882 review_comments,
1883 blocking: Blocking::of(&raw.merge_state_status),
1884 })
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq)]
1889pub(crate) struct CheckView {
1890 pub name: String,
1891 pub verdict: Verdict,
1892 pub run: Option<String>,
1894 pub url: Option<String>,
1895}
1896
1897#[derive(Debug, Clone, PartialEq, Eq)]
1901pub(crate) struct RollupView {
1902 pub url: String,
1903 pub number: u64,
1904 pub state: PrLifecycle,
1905 pub head: String,
1906 pub checks: Vec<CheckView>,
1907}
1908
1909pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1912 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1913 let state = match raw.state.to_ascii_uppercase().as_str() {
1914 "OPEN" => PrLifecycle::Open,
1915 "MERGED" => PrLifecycle::Merged,
1916 "CLOSED" => PrLifecycle::Closed,
1917 other => bail!("unknown pull request state `{other}`"),
1918 };
1919 let checks = raw
1920 .status_check_rollup
1921 .iter()
1922 .map(|c| CheckView {
1923 name: c.label(),
1924 verdict: c.verdict(),
1925 run: c.url().and_then(run_of),
1926 url: c.url().map(str::to_owned),
1927 })
1928 .collect();
1929 Ok(RollupView {
1930 url: raw.url,
1931 number: raw.number,
1932 state,
1933 head: raw.head_ref_oid,
1934 checks,
1935 })
1936}
1937
1938pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1948 let view = gh(
1949 repo,
1950 &[
1951 "pr".to_owned(),
1952 "view".to_owned(),
1953 pr_url.to_owned(),
1954 "--json".to_owned(),
1955 "state".to_owned(),
1956 ],
1957 )
1958 .await?;
1959 if !view.0 {
1960 bail!("gh pr view {pr_url}: {}", view.1);
1961 }
1962 Ok(parse_pr(&view.1)?.state)
1966}
1967
1968#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1972pub struct ExternalMerge {
1973 pub url: String,
1975 pub number: u64,
1977}
1978
1979#[derive(Debug, Deserialize)]
1980#[serde(rename_all = "camelCase")]
1981struct GhMergedPr {
1982 url: String,
1983 number: u64,
1984 merged_at: String,
1985 base_ref_name: String,
1986}
1987
1988fn pick_merged_pr(
2002 json: &str,
2003 base_branch: &str,
2004 created_at: Timestamp,
2005) -> Result<Option<ExternalMerge>> {
2006 let raw: Vec<GhMergedPr> =
2007 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2008 let mut matches: Vec<ExternalMerge> = Vec::new();
2009 for pr in raw {
2010 if pr.base_ref_name != base_branch {
2011 continue;
2012 }
2013 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2014 continue;
2015 };
2016 if merged_at < created_at {
2017 continue;
2018 }
2019 matches.push(ExternalMerge {
2020 url: pr.url,
2021 number: pr.number,
2022 });
2023 }
2024 if matches.len() == 1 {
2025 Ok(matches.pop())
2026 } else {
2027 Ok(None)
2028 }
2029}
2030
2031#[derive(Debug, Clone, PartialEq, Eq)]
2033pub enum OpenPr {
2034 None,
2036 One {
2038 url: String,
2040 title: String,
2042 },
2043 Many(Vec<String>),
2045}
2046
2047#[derive(Debug, Deserialize)]
2048#[serde(rename_all = "camelCase")]
2049struct GhOpenPr {
2050 url: String,
2054 #[serde(default)]
2055 title: String,
2056 base_ref_name: String,
2057 #[serde(default)]
2060 is_cross_repository: bool,
2061}
2062
2063pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2069 let raw: Vec<GhOpenPr> =
2070 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2071 let mut hits: Vec<GhOpenPr> = raw
2072 .into_iter()
2073 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2074 .collect();
2075 Ok(match hits.len() {
2076 0 => OpenPr::None,
2077 1 => {
2078 let p = hits.remove(0);
2079 OpenPr::One {
2080 url: p.url,
2081 title: p.title,
2082 }
2083 }
2084 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2085 })
2086}
2087
2088pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2092 let (ok, out) = gh(
2093 repo,
2094 &[
2095 "pr".to_owned(),
2096 "list".to_owned(),
2097 "--head".to_owned(),
2098 branch.to_owned(),
2099 "--base".to_owned(),
2100 base.to_owned(),
2101 "--state".to_owned(),
2102 "open".to_owned(),
2103 "--json".to_owned(),
2104 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2105 ],
2106 )
2107 .await?;
2108 if !ok {
2109 bail!("gh pr list failed: {out}");
2110 }
2111 pick_open_pr(&out, base)
2112}
2113
2114#[derive(Debug, Deserialize)]
2115#[serde(rename_all = "camelCase")]
2116struct GhPrHead {
2117 head_ref_name: String,
2118 base_ref_name: String,
2119 state: String,
2120 is_cross_repository: bool,
2123 head_ref_oid: String,
2126}
2127
2128#[derive(Debug, Clone, PartialEq, Eq)]
2130pub struct Refusal {
2131 pub retry: bool,
2134 pub why: String,
2136}
2137
2138impl Refusal {
2139 fn final_(why: String) -> Self {
2140 Self { retry: false, why }
2141 }
2142}
2143
2144pub fn closable(
2153 json: &str,
2154 branch: &str,
2155 base: &str,
2156 verified: &[String],
2157) -> std::result::Result<(), Refusal> {
2158 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2159 retry: true,
2160 why: format!("could not read the pull request ({e})"),
2161 })?;
2162 if pr.head_ref_name != branch {
2163 return Err(Refusal::final_(format!(
2164 "its head is `{}`, not this run's `{branch}`",
2165 pr.head_ref_name
2166 )));
2167 }
2168 if pr.is_cross_repository {
2169 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2170 }
2171 if pr.base_ref_name != base {
2172 return Err(Refusal::final_(format!(
2173 "it targets `{}`, not `{base}`",
2174 pr.base_ref_name
2175 )));
2176 }
2177 if !pr.state.eq_ignore_ascii_case("open") {
2178 return Err(Refusal::final_(format!(
2179 "it is already {}",
2180 pr.state.to_ascii_lowercase()
2181 )));
2182 }
2183 if !verified.contains(&pr.head_ref_oid) {
2187 return Err(Refusal {
2188 retry: true,
2189 why: format!(
2190 "its head {} is not a commit this run checked against the base",
2191 crate::already::short_sha(&pr.head_ref_oid)
2192 ),
2193 });
2194 }
2195 Ok(())
2196}
2197
2198async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2203 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2204 return false;
2205 };
2206 is_forge_url(url.trim())
2207}
2208
2209fn is_forge_url(url: &str) -> bool {
2210 url.contains("://") && !url.starts_with("file://")
2211 || url
2212 .split_once(':')
2213 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2214}
2215
2216fn forge_unavailable(message: &str) -> bool {
2219 message.contains("known GitHub host") || message.contains("spawn gh")
2220}
2221
2222pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2225 let how = match evidence.proof {
2226 crate::already::Proof::PatchId => format!(
2227 "carried by commit {} on `{base}` with the same patch",
2228 evidence.names()
2229 ),
2230 crate::already::Proof::Ancestry => {
2231 format!("already in the history of `{base}` as {}", evidence.names())
2232 }
2233 crate::already::Proof::Tree => format!(
2234 "already part of `{base}` (merging this branch changes nothing at {})",
2235 crate::already::short_sha(&evidence.tip)
2236 ),
2237 };
2238 format!(
2239 "Closing: everything this branch adds is {how}, so there is nothing left to \
2240 land. This pull request was closed automatically after that was verified; \
2241 reopen it if you disagree."
2242 )
2243}
2244
2245pub async fn close_superseded_pr(
2257 state: &mut RunState,
2258 branch: &str,
2259 evidence: &crate::already::Evidence,
2260 verified: &[String],
2261) -> Result<std::result::Result<String, String>> {
2262 let repo = state.repo.clone();
2263 let base = state.base_branch.clone();
2264 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2265 Some(p) => p.url.clone(),
2266 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2271 return Ok(Err(
2272 "the remote is not a forge, so there is no pull request".to_owned(),
2273 ));
2274 }
2275 None => match find_open_pr(&repo, branch, &base).await {
2276 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2277 return Ok(Err(format!("no forge to ask: {e:#}")));
2278 }
2279 Err(e) => return Err(e),
2280 Ok(OpenPr::One { url, .. }) => url,
2281 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2282 Ok(OpenPr::Many(urls)) => {
2283 return Ok(Err(format!(
2284 "{} open pull requests name it; not choosing between them",
2285 urls.len()
2286 )));
2287 }
2288 },
2289 };
2290 let (ok, view) = gh(
2291 &repo,
2292 &[
2293 "pr".to_owned(),
2294 "view".to_owned(),
2295 url.clone(),
2296 "--json".to_owned(),
2297 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2298 ],
2299 )
2300 .await?;
2301 if !ok {
2302 bail!("gh pr view {url} failed: {view}");
2303 }
2304 if let Err(refusal) = closable(&view, branch, &base, verified) {
2305 if refusal.retry {
2308 bail!("left {url} open: {}", refusal.why);
2309 }
2310 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2311 }
2312 let (ok, out) = gh(
2313 &repo,
2314 &[
2315 "pr".to_owned(),
2316 "close".to_owned(),
2317 url.clone(),
2318 "--comment".to_owned(),
2319 superseded_comment(&base, evidence),
2320 ],
2321 )
2322 .await?;
2323 if !ok {
2324 bail!("gh pr close {url} failed: {out}");
2325 }
2326 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2327 p.state = "closed".to_owned();
2328 }
2329 Ok(Ok(url))
2330}
2331
2332pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2336 let (title, _) = crate::github_text::prepare(state, title, "");
2337 edit_pr_title(repo, url, &title).await
2338}
2339
2340pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2344 edit_pr_title(repo, url, title).await
2345}
2346
2347pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2350 let (ok, _) = gh(
2351 repo,
2352 &[
2353 "pr".to_owned(),
2354 "edit".to_owned(),
2355 url.to_owned(),
2356 "--title".to_owned(),
2357 title.to_owned(),
2358 "--body".to_owned(),
2359 body.to_owned(),
2360 ],
2361 )
2362 .await
2363 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2364 if !ok {
2365 bail!("gh pr edit failed while posting the chosen rewrite");
2367 }
2368 Ok(())
2369}
2370
2371async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2372 let (ok, out) = gh(
2373 repo,
2374 &[
2375 "pr".to_owned(),
2376 "edit".to_owned(),
2377 url.to_owned(),
2378 "--title".to_owned(),
2379 title.to_owned(),
2380 ],
2381 )
2382 .await?;
2383 if !ok {
2384 bail!("gh pr edit failed: {out}");
2385 }
2386 Ok(())
2387}
2388
2389pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2402 let Some(winner) = state.winner() else {
2403 return Ok(None);
2404 };
2405 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2406 let out = gh(
2407 &state.repo,
2408 &[
2409 "pr".to_owned(),
2410 "list".to_owned(),
2411 "--head".to_owned(),
2412 branch.clone(),
2413 "--state".to_owned(),
2414 "merged".to_owned(),
2415 "--json".to_owned(),
2416 "url,number,mergedAt,baseRefName".to_owned(),
2417 ],
2418 )
2419 .await?;
2420 if !out.0 {
2421 bail!("gh pr list --head {branch}: {}", out.1);
2422 }
2423 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2424}
2425
2426pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2438 let out = tokio::process::Command::new("git")
2439 .args(["merge-base", "--is-ancestor", branch, base_branch])
2440 .current_dir(repo)
2441 .quiet()
2442 .stdin(std::process::Stdio::null())
2443 .output()
2444 .await
2445 .context("spawn git merge-base --is-ancestor")?;
2446 Ok(out.status.success())
2447}
2448
2449fn forge_slug(url: &str) -> Option<(String, &str)> {
2457 let rest = url.rsplit("://").next()?;
2458 let (host, path) = rest.split_once('/')?;
2459 if host.is_empty() {
2460 return None;
2461 }
2462 Some((host.to_ascii_lowercase(), path))
2463}
2464
2465pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2473 let (host, path) = forge_slug(url)?;
2474 let mut segments = path.split('/');
2475 let owner = segments.next()?;
2476 let repo = segments.next()?;
2477 let kind = segments.next()?;
2478 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2479 return None;
2480 }
2481 Some(format!("{host}/{owner}/{repo}"))
2482}
2483
2484fn slug_of_repo_url(url: &str) -> Option<String> {
2488 let (host, path) = forge_slug(url)?;
2489 let mut segments = path.split('/');
2490 let owner = segments.next()?;
2491 let repo = segments.next()?;
2492 if owner.is_empty() || repo.is_empty() {
2493 return None;
2494 }
2495 Some(format!("{host}/{owner}/{repo}"))
2496}
2497
2498pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2519 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2520 return Ok(());
2521 }
2522 bail!(
2523 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2524 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2525 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2526 verify by hand before treating this as a false positive)"
2527 );
2528}
2529
2530async fn repo_slug(repo: &Path) -> Result<String> {
2541 let out = gh(
2542 repo,
2543 &[
2544 "repo".to_owned(),
2545 "view".to_owned(),
2546 "--json".to_owned(),
2547 "url".to_owned(),
2548 ],
2549 )
2550 .await?;
2551 if !out.0 {
2552 bail!("gh repo view --json url: {}", out.1);
2553 }
2554 #[derive(Debug, Deserialize)]
2555 struct GhRepo {
2556 url: String,
2557 }
2558 let parsed: GhRepo = serde_json::from_str(&out.1)
2559 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2560 slug_of_repo_url(&parsed.url)
2561 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2562}
2563
2564pub async fn correct_manual_merge(
2612 state: &mut RunState,
2613 url: &str,
2614) -> Result<(RunStatus, RunStatus)> {
2615 let Some(pr_slug) = slug_of_pr_url(url) else {
2616 bail!(
2617 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2618 this pull request belongs to"
2619 );
2620 };
2621 let run_slug = repo_slug(&state.repo).await?;
2622 ensure_same_repo(&run_slug, &pr_slug)?;
2623 correct_merge(state, url).await
2624}
2625
2626pub(crate) async fn correct_confirmed_external_merge(
2637 state: &mut RunState,
2638 url: &str,
2639) -> Result<(RunStatus, RunStatus)> {
2640 correct_merge(state, url).await
2641}
2642
2643fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2645 let Some(pr) = a.pr.as_ref() else {
2646 return false;
2647 };
2648 if !url.is_empty()
2649 && pr
2650 .url
2651 .trim_end_matches('/')
2652 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2653 {
2654 return true;
2655 }
2656 number > 0
2657 && pr.number == number
2658 && match (a.repo.canonicalize(), repo.canonicalize()) {
2659 (Ok(x), Ok(y)) => x == y,
2660 _ => a.repo == repo,
2661 }
2662}
2663
2664fn rewrite_open_prs(
2678 home: &Path,
2679 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2680) -> usize {
2681 let now = Timestamp::now();
2682 let mut changed = 0;
2683 for id in crate::run::list_ids_in(&home.join("runs")) {
2684 let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2685 continue;
2686 };
2687 let path = home.join("runs").join(&id).join("run.json");
2688 let Ok(body) = std::fs::read_to_string(&path) else {
2689 continue;
2690 };
2691 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2692 continue;
2693 };
2694 if !state.status.done()
2695 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2696 || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2697 != crate::run::Liveness::Dead
2698 {
2699 continue;
2700 }
2701 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2702 continue;
2703 };
2704 if let Some(pr) = state.pr.as_mut() {
2705 pr.state = to.as_str().to_owned();
2706 }
2707 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2708 state.event(
2709 "land",
2710 format!("recorded {url} as {}: another run settled it", to.as_str()),
2711 );
2712 match state.save_under(home) {
2713 Ok(()) => changed += 1,
2714 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2715 }
2716 }
2717 changed
2718}
2719
2720pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2725 if to == PrLifecycle::Open {
2726 return;
2727 }
2728 let Some(home) = crate::run::try_home() else {
2729 return;
2730 };
2731 write_pr_state_through_in(&home, run, to);
2732}
2733
2734pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2735 let Some(pr) = run.pr.as_ref() else {
2736 return 0;
2737 };
2738 let (url, number) = (pr.url.clone(), pr.number);
2739 rewrite_open_prs(home, &mut |other| {
2740 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2741 })
2742}
2743
2744pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2747 let now = Timestamp::now();
2748 let mut out = Vec::new();
2749 for id in crate::run::list_ids_in(&home.join("runs")) {
2750 let path = home.join("runs").join(&id).join("run.json");
2751 let Ok(body) = std::fs::read_to_string(&path) else {
2752 continue;
2753 };
2754 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2755 continue;
2756 };
2757 if let Some(pr) = state.pr.as_ref()
2758 && state.status.done()
2759 && pr.state == "open"
2760 && !pr.url.is_empty()
2761 && !crate::daemon::is_working_on(home, &id, now)
2762 {
2763 out.push((id, state.repo.clone(), pr.url.clone()));
2764 }
2765 }
2766 out
2767}
2768
2769pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2772 rewrite_open_prs(home, &mut |s| {
2773 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2774 })
2775}
2776
2777pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2784 let mut known = BTreeMap::new();
2785 let mut failed = 0;
2786 let mut seen = BTreeSet::new();
2787 for (_, repo, url) in stale_open_prs(home) {
2788 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2789 continue;
2790 }
2791 match lifecycle(&repo, &url).await {
2792 Ok(state) => {
2793 known.insert(url, state);
2794 }
2795 Err(e) => {
2796 tracing::warn!("repair pr state of {url}: {e:#}");
2797 failed += 1;
2798 }
2799 }
2800 }
2801 (apply_pr_states(home, &known), failed)
2802}
2803
2804async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2805 match lifecycle(&state.repo, url).await? {
2806 PrLifecycle::Merged => {}
2807 other => bail!(
2808 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2809 other.as_str(),
2810 state.id
2811 ),
2812 }
2813 let before = state.status;
2814 if let Err(e) = land(state, url).await {
2815 state.status = RunStatus::Blocked;
2822 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2823 state.save()?;
2824 return Err(e).context(format!("confirming the merge of {url}"));
2825 }
2826 state.event(
2827 "fold",
2828 "operator recorded this pull request as a manual merge; this run never \
2829 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2830 bump this change might warrant has to be filed by hand",
2831 );
2832 if state.status == RunStatus::Merged {
2835 crate::followup::after_merge(state, url).await;
2836 }
2837 state.save()?;
2838 Ok((before, state.status))
2839}
2840
2841pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2848 let raw: Vec<GhInline> =
2849 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2850 let mut out = Vec::new();
2851 for c in raw {
2852 push_if_outstanding(
2853 &mut out,
2854 ReviewComment {
2855 author: c.user.login,
2856 path: c.path,
2857 line: c.line,
2858 body: c.body,
2859 },
2860 );
2861 }
2862 Ok(out)
2863}
2864
2865fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2871 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2872 return;
2873 }
2874 if comment.path.is_none() && is_noise(&comment.body) {
2875 return;
2876 }
2877 out.push(comment);
2878}
2879
2880pub fn is_noise(body: &str) -> bool {
2898 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2899 return true;
2900 }
2901 let mut content = false;
2902 for line in strip_blocks(body).lines() {
2903 let line = unquote(line);
2904 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2905 continue;
2906 }
2907 content = true;
2908 break;
2909 }
2910 !content
2911}
2912
2913fn strip_blocks(body: &str) -> String {
2915 let mut out = String::with_capacity(body.len());
2916 let mut rest = body;
2917 loop {
2918 let open = ["<!--", "<details>"]
2919 .iter()
2920 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2921 .min_by_key(|(i, _)| *i);
2922 let Some((at, tag)) = open else {
2923 out.push_str(rest);
2924 return out;
2925 };
2926 out.push_str(&rest[..at]);
2927 let after = &rest[at + tag.len()..];
2928 let close = if tag == "<!--" { "-->" } else { "</details>" };
2929 match after.find(close) {
2930 Some(end) => rest = &after[end + close.len()..],
2931 None => return out,
2933 }
2934 }
2935}
2936
2937fn unquote(line: &str) -> &str {
2939 let mut s = line.trim();
2940 while let Some(rest) = s.strip_prefix('>') {
2941 s = rest.trim_start();
2942 }
2943 s.trim()
2944}
2945
2946fn is_checklist(line: &str) -> bool {
2948 let rest = line
2949 .strip_prefix("- ")
2950 .or_else(|| line.strip_prefix("* "))
2951 .unwrap_or("");
2952 let rest = rest.trim_start();
2953 matches!(
2954 rest.get(..3),
2955 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2956 )
2957}
2958
2959fn is_decoration(line: &str) -> bool {
2961 line.starts_with('#')
2962 || line.starts_with("[!")
2963 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2964}
2965
2966fn is_banner(line: &str) -> bool {
2973 let plain = drop_spans(line, "**", "**");
2974 let plain = if plain.contains("](") {
2975 drop_spans(&plain, "[", ")")
2976 } else {
2977 plain
2978 };
2979 !plain.chars().any(char::is_alphanumeric)
2980}
2981
2982fn drop_spans(s: &str, open: &str, close: &str) -> String {
2986 let mut out = String::with_capacity(s.len());
2987 let mut rest = s;
2988 while let Some(at) = rest.find(open) {
2989 out.push_str(&rest[..at]);
2990 let after = &rest[at + open.len()..];
2991 match after.find(close) {
2992 Some(end) => rest = &after[end + close.len()..],
2993 None => return out,
2994 }
2995 }
2996 out.push_str(rest);
2997 out
2998}
2999
3000fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3018 static LOCKS: std::sync::LazyLock<
3019 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3020 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3021 LOCKS
3022 .lock()
3023 .unwrap_or_else(std::sync::PoisonError::into_inner)
3024 .entry(repo.to_path_buf())
3025 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3026 .clone()
3027}
3028
3029fn repo_label(repo: &Path, pr_url: &str) -> String {
3032 let parts: Vec<&str> = pr_url.split('/').collect();
3033 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3034 && at >= 2
3035 && !parts[at - 1].is_empty()
3036 && !parts[at - 2].is_empty()
3037 {
3038 return format!("{}/{}", parts[at - 2], parts[at - 1]);
3039 }
3040 repo.file_name()
3041 .map(|n| n.to_string_lossy().into_owned())
3042 .unwrap_or_default()
3043}
3044
3045fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3049 (pr.checks == Checks::Red).then(|| {
3050 let details = crate::notices::format_list(&pr.failing)
3051 .map(|names| format!(": {names}"))
3052 .unwrap_or_default();
3053 format!(
3054 "Merged {repo_name} PR #{} with red checks{details} ({})",
3055 pr.number, pr.url
3056 )
3057 })
3058}
3059
3060async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3064 let repo_name = repo_label(&state.repo, &pr.url);
3065 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3066 return;
3067 };
3068 if let Some(rec) = state.pr.as_mut() {
3069 rec.red_at_merge = pr.failing.clone();
3070 }
3071 state.event("land", summary.clone());
3072 crate::notices::raise_with(
3075 crate::notices::merged_red(&state.id, &summary),
3076 &state.config.notify,
3077 );
3078}
3079
3080pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3087 land_with(state, pr_url, &GhForge).await
3088}
3089
3090trait Forge {
3094 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3095 let (ok, out) = gh(
3096 repo,
3097 &[
3098 "pr".into(),
3099 "close".into(),
3100 pr_url.into(),
3101 "--comment".into(),
3102 comment.into(),
3103 ],
3104 )
3105 .await?;
3106 if !ok {
3107 bail!("PR closure failed: {out}");
3108 }
3109 Ok(())
3110 }
3111 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3112 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3113 async fn poll(&self);
3114 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3116 fn now(&self) -> i64;
3118 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3121 #[allow(clippy::too_many_arguments)]
3122 async fn fix(
3123 &self,
3124 state: &mut RunState,
3125 pr: &PrState,
3126 round: usize,
3127 budget: usize,
3128 reason: &str,
3129 logs: &str,
3130 ) -> Result<Fixed>;
3131}
3132
3133struct GhForge;
3134
3135impl Forge for GhForge {
3136 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3137 observe(repo, pr_url).await
3138 }
3139 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3140 gh(repo, argv).await
3141 }
3142 async fn poll(&self) {
3143 tokio::time::sleep(POLL).await;
3144 }
3145 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3146 let args = [
3147 "run".to_owned(),
3148 "rerun".to_owned(),
3149 run.to_owned(),
3150 "--failed".to_owned(),
3151 ];
3152 let (ok, out) = gh(repo, &args).await?;
3153 if !ok {
3154 bail!("gh run rerun {run}: {out}");
3155 }
3156 Ok(())
3157 }
3158 fn now(&self) -> i64 {
3159 jiff::Timestamp::now().as_second()
3160 }
3161 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3162 required_contexts_of(repo, base).await
3163 }
3164 async fn fix(
3165 &self,
3166 state: &mut RunState,
3167 pr: &PrState,
3168 round: usize,
3169 budget: usize,
3170 reason: &str,
3171 logs: &str,
3172 ) -> Result<Fixed> {
3173 fix_round(state, pr, round, budget, reason, logs).await
3174 }
3175}
3176
3177fn encode_path_segment(s: &str) -> String {
3179 let mut out = String::new();
3180 for b in s.bytes() {
3181 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3182 out.push(b as char);
3183 } else {
3184 let _ = write!(out, "%{b:02X}");
3185 }
3186 }
3187 out
3188}
3189
3190async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3195 let enc = encode_path_segment(base);
3196 let mut all = BTreeSet::new();
3197 let classic = gh(
3200 repo,
3201 &[
3202 "api".to_owned(),
3203 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3204 ],
3205 )
3206 .await
3207 .ok()?;
3208 if classic.0 {
3209 all.extend(parse_classic_required(&classic.1)?);
3210 } else if !classic.1.contains("404") {
3211 return None;
3212 }
3213 let rules = gh(
3214 repo,
3215 &[
3216 "api".to_owned(),
3217 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3218 ],
3219 )
3220 .await
3221 .ok()?;
3222 if !rules.0 {
3223 return None;
3224 }
3225 all.extend(parse_ruleset_required(&rules.1)?);
3226 Some(all)
3227}
3228
3229fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3232 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3233 let mut out = BTreeSet::new();
3234 for c in v.get("contexts")?.as_array()? {
3235 out.insert(c.as_str()?.to_owned());
3236 }
3237 for c in v
3238 .get("checks")
3239 .and_then(|c| c.as_array())
3240 .into_iter()
3241 .flatten()
3242 {
3243 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3244 out.insert(name.to_owned());
3245 }
3246 }
3247 Some(out)
3248}
3249
3250fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3252 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3253 let mut out = BTreeSet::new();
3254 for rule in v.as_array()? {
3255 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3256 continue;
3257 }
3258 let checks = rule
3259 .pointer("/parameters/required_status_checks")?
3260 .as_array()?;
3261 for c in checks {
3262 out.insert(c.get("context")?.as_str()?.to_owned());
3263 }
3264 }
3265 Some(out)
3266}
3267
3268fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3276 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3277}
3278
3279fn bound_head<'a>(
3289 seen_head: &'a str,
3290 rollup_head: &str,
3291 awaiting: Option<&str>,
3292) -> Option<&'a str> {
3293 if seen_head.is_empty()
3294 || awaiting_new_head(awaiting, seen_head)
3295 || !rollup_head.eq_ignore_ascii_case(seen_head)
3296 {
3297 return None;
3298 }
3299 Some(seen_head)
3300}
3301
3302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3304enum Refused {
3305 Pending,
3307 Recheck,
3311 Final,
3314}
3315
3316fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3319 let Some(after) = after else {
3320 return Refused::Pending;
3322 };
3323 if after.pr.state != PrLifecycle::Open {
3324 return Refused::Final;
3325 }
3326 if !after.head.eq_ignore_ascii_case(observed_head) {
3329 return Refused::Pending;
3330 }
3331 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3333 return Refused::Pending;
3334 }
3335 let state = after.merge_state.to_ascii_uppercase();
3336 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3337 || state.is_empty()
3338 || state == "UNKNOWN"
3339 {
3340 return Refused::Pending;
3341 }
3342 if rechecked {
3343 Refused::Final
3344 } else {
3345 Refused::Recheck
3346 }
3347}
3348
3349async fn disarm<F: Forge>(
3354 forge: &F,
3355 state: &mut RunState,
3356 repo: &Path,
3357 number: u64,
3358) -> std::result::Result<(), String> {
3359 let argv = disable_automerge_argv(number);
3360 let out = {
3361 let merge_lock = repo_merge_lock(repo);
3362 let _merge_slot = merge_lock.lock().await;
3363 forge.merge(repo, &argv).await
3364 };
3365 match out {
3366 Ok((true, _)) => {
3367 state.land_armed_head = None;
3368 state.event("land", "auto-merge disabled");
3369 state.save().map_err(|e| format!("{e:#}"))?;
3370 Ok(())
3371 }
3372 Ok((false, msg)) => Err(msg),
3373 Err(e) => Err(format!("{e:#}")),
3374 }
3375}
3376
3377async fn stop_disarmed<F: Forge>(
3381 forge: &F,
3382 state: &mut RunState,
3383 repo: &Path,
3384 pr: &PrState,
3385 why: &str,
3386) -> Result<()> {
3387 if state.land_armed_head.is_none() {
3388 return stop(state, repo, pr, why).await;
3389 }
3390 match disarm(forge, state, repo, pr.number).await {
3391 Ok(()) => stop(state, repo, pr, why).await,
3392 Err(e) => {
3393 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3394 stop(state, repo, pr, &why).await
3395 }
3396 }
3397}
3398
3399fn owner_requested_stop(state: &RunState) -> Result<bool> {
3401 let Some(binding) = &state.land_approval else {
3402 return Ok(false);
3403 };
3404 let q = ask::Questions::open().get(&binding.question)?;
3405 Ok(q.run == state.id
3406 && q.node == APPROVAL_NODE
3407 && q.resolution()
3408 .as_deref()
3409 .is_some_and(crate::merge_decision::stops))
3410}
3411
3412async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3415 use crate::merge_decision as md;
3416 let Some(binding) = &state.land_approval else {
3417 return Ok(false);
3418 };
3419 let q = ask::Questions::open().get(&binding.question)?;
3420 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3421 return Ok(false);
3422 };
3423 if q.run != state.id || q.node != APPROVAL_NODE {
3424 bail!("stop decision names another run");
3425 }
3426 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3427 pr: pr.url.clone(),
3428 head: binding.head.clone(),
3429 language: state.config.graph.language.clone(),
3430 });
3431 if target.pr != pr.url {
3432 bail!("stop decision names another PR");
3433 }
3434 if state.merge_decision.is_none() {
3435 state.merge_decision = Some(md::Decision {
3436 question: q.id.clone(),
3437 target,
3438 choice: choice.clone(),
3439 closed: false,
3440 replacement_run: None,
3441 replacement_pr: None,
3442 });
3443 state.save()?;
3444 }
3445 let repo = state.repo.clone();
3446 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3448 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3449 return Ok(true);
3450 }
3451 if choice.eq_ignore_ascii_case(HOLD) {
3452 stop(
3453 state,
3454 &repo,
3455 pr,
3456 "owner held the decision: PR left open; no further repair or merge work",
3457 )
3458 .await?;
3459 return Ok(true);
3460 }
3461 if md::selected_agent(&choice).is_some()
3462 || state
3463 .origin
3464 .as_ref()
3465 .and_then(|o| o.task.as_ref())
3466 .is_some()
3467 {
3468 let owners: Vec<_> = crate::queue::Queue::open()
3469 .list()
3470 .into_iter()
3471 .filter(|task| task.runs.last() == Some(&state.id))
3472 .collect();
3473 let owner = (owners.len() == 1).then(|| &owners[0]);
3474 if !owner.is_some_and(|task| {
3475 task.runs.last() == Some(&state.id)
3476 && (!task
3477 .operator_decided_at
3478 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3479 || task.resumption.as_ref().is_some_and(|release| {
3480 release.question_id.as_deref() == Some(q.id.as_str())
3481 && task.operator_decided_at == Some(release.at)
3482 }))
3483 }) {
3484 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3485 return Ok(true);
3486 }
3487 }
3488 if pr.state == PrLifecycle::Merged {
3489 stop(
3490 state,
3491 &repo,
3492 pr,
3493 "PR already merged; withdrawal/replacement cannot be applied",
3494 )
3495 .await?;
3496 return Ok(true);
3497 }
3498 let comment = format!(
3499 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3500 q.id, choice, state.id
3501 );
3502 if pr.state == PrLifecycle::Open {
3503 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3504 stop(
3505 state,
3506 &repo,
3507 pr,
3508 &format!(
3509 "owner requested closure but it failed: {e}; task held, no replacement started"
3510 ),
3511 )
3512 .await?;
3513 return Ok(true);
3514 }
3515 }
3516 let confirmed = forge.view(&repo, &pr.url).await?;
3517 if confirmed.pr.state != PrLifecycle::Closed {
3518 stop(
3519 state,
3520 &repo,
3521 pr,
3522 "PR closure was not confirmed; task held, no replacement started",
3523 )
3524 .await?;
3525 return Ok(true);
3526 }
3527 *pr = confirmed.pr;
3528 state.merge_decision.as_mut().unwrap().closed = true;
3529 if let Some(record) = &mut state.pr {
3530 record.state = "closed".into();
3531 }
3532 state.status = RunStatus::Superseded;
3533 state.merge = Some(MergeOutcome {
3534 mode: MergeMode::Pr,
3535 ok: false,
3536 detail: format!("owner decision: {choice}; PR closure confirmed"),
3537 empty: false,
3538 });
3539 state.event(
3540 "land",
3541 format!(
3542 "{} applied: PR closure confirmed; old execution retired",
3543 choice
3544 ),
3545 );
3546 state.save()?;
3547 Ok(true)
3548}
3549
3550async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3551 crate::pr_head::ensure_owner(state)?;
3552 let reopening_hold = crate::merge_decision::resumable_hold(state);
3553 let repo = state.repo.clone();
3554 let budget = state.config.graph.land_rounds;
3555 let mut round = 0usize;
3556 let mut rebases = 0usize;
3559 let mut waited = Duration::ZERO;
3560 let mut shown: BTreeSet<String> = BTreeSet::new();
3565 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3567 let mut rechecked = false;
3569
3570 state.status = RunStatus::Landing;
3578 state.event("land", format!("watching {pr_url}"));
3579 state.save()?;
3580
3581 let mut resumed_armed = state.land_armed_head.is_some();
3585
3586 loop {
3587 let seen = forge.view(&repo, pr_url).await?;
3588 let mut pr = seen.pr.clone();
3589 pr.review_comments.retain(|c| !shown.contains(&c.body));
3590 state.pr = Some(crate::run::PrRecord {
3591 url: pr.url.clone(),
3592 number: pr.number,
3593 state: pr.state.as_str().to_owned(),
3594 checks: pr.checks.as_str().to_owned(),
3595 round,
3596 rounds: budget,
3597 red_at_merge: Vec::new(),
3598 });
3599 state.save()?;
3600
3601 crate::pr_head::ensure_owner(state)?;
3602 if reopening_hold {
3603 anyhow::ensure!(
3604 pr.state == PrLifecycle::Open,
3605 "held PR is no longer open; cannot request a fresh decision"
3606 );
3607 disarm(forge, state, &repo, pr.number)
3610 .await
3611 .map_err(anyhow::Error::msg)?;
3612 let previous = state.merge_decision.take();
3613 let binding = state.land_approval.take();
3614 state.config.graph.land_approval = true;
3615 let subject = merge_subject(
3616 crate::graph::landing_title(state, &seen.title),
3617 &crate::graph::landing_subject_source(state),
3618 );
3619 let contested = contested_to_ask(state);
3620 if let Err(error) =
3621 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3622 {
3623 state.merge_decision = previous;
3624 state.land_approval = binding;
3625 return Err(error);
3626 }
3627 state.end_pass_parked(crate::run::ParkCause::Question);
3628 state.parked = true;
3629 state.event("land", format!("explicit resume reopened held decision {}; parked awaiting a fresh owner answer", previous.as_ref().map(|d| d.question.as_str()).unwrap_or_default()));
3630 state.save()?;
3631 return Ok(pr);
3632 }
3633 if owner_stop(state, &mut pr, forge).await? {
3634 return Ok(pr);
3635 }
3636
3637 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3638 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3643 let why = format!(
3644 "a previous pass may have armed auto-merge and it could not be disabled \
3645 on resume: {e}"
3646 );
3647 stop(state, &repo, &pr, &why).await?;
3648 return Ok(pr);
3649 }
3650 }
3651
3652 if pr.state == PrLifecycle::Open
3657 && !seen.head.is_empty()
3658 && state
3659 .land_armed_head
3660 .as_deref()
3661 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3662 {
3663 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3664 let why = format!(
3665 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3666 );
3667 stop(state, &repo, &pr, &why).await?;
3668 return Ok(pr);
3669 }
3670 }
3671
3672 if pr.state == PrLifecycle::Open {
3673 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3674 if waited >= WAIT_CEILING {
3675 let want = awaiting_head.as_deref().unwrap_or_default();
3676 let why = format!(
3677 "the pull request's checks were still not about one readable head after \
3678 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3679 someone may have pushed over it",
3680 WAIT_CEILING.as_secs() / 60,
3681 if want.is_empty() { "any" } else { want },
3682 if seen.head.is_empty() {
3683 "nothing readable"
3684 } else {
3685 &seen.head
3686 },
3687 if seen.rollup_head.is_empty() {
3688 "nothing readable"
3689 } else {
3690 &seen.rollup_head
3691 },
3692 );
3693 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3694 return Ok(pr);
3695 }
3696 waited += POLL;
3697 forge.poll().await;
3698 continue;
3699 }
3700 if awaiting_head.take().is_some() {
3703 waited = Duration::ZERO;
3706 }
3707 }
3708
3709 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3714 let failing = rerunnable(&seen.contexts);
3715 let now = forge.now();
3716 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3717 RerunStep::Rerun(runs) => {
3718 let rec = state.land_reruns.get_or_insert_with(Default::default);
3719 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3720 *rec = LandReruns {
3721 head: seen.head.clone(),
3722 runs: BTreeMap::new(),
3723 };
3724 }
3725 for run in &runs {
3726 rec.runs.insert(run.clone(), now);
3727 }
3728 state.save()?;
3732 let mut any = false;
3733 for run in &runs {
3734 let names = failing
3735 .iter()
3736 .filter(|(_, r)| r.as_deref() == Some(run))
3737 .map(|(l, _)| l.as_str())
3738 .collect::<Vec<_>>()
3739 .join(", ");
3740 match forge.rerun(&repo, run).await {
3741 Ok(()) => {
3742 any = true;
3743 state.event(
3744 "land",
3745 format!("reran failed check(s) {names} (run {run})"),
3746 );
3747 }
3748 Err(e) => state.event(
3750 "land",
3751 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3752 ),
3753 }
3754 }
3755 state.save()?;
3756 if any {
3757 waited = Duration::ZERO;
3758 forge.poll().await;
3759 continue;
3760 }
3761 }
3762 RerunStep::Grace => {
3763 if waited >= WAIT_CEILING {
3764 let why = format!(
3765 "checks were still failing after a rerun and {} minutes",
3766 WAIT_CEILING.as_secs() / 60
3767 );
3768 stop(state, &repo, &pr, &why).await?;
3769 return Ok(pr);
3770 }
3771 waited += POLL;
3772 forge.poll().await;
3773 continue;
3774 }
3775 RerunStep::Fix => {}
3776 }
3777 }
3778
3779 let step = decide(&pr, round, budget, waited);
3780 let armed_here = state
3785 .land_armed_head
3786 .as_deref()
3787 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3788 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3789 if waited >= WAIT_CEILING {
3790 let required = if seen.base.is_empty() {
3791 None
3792 } else {
3793 forge.required_contexts(&repo, &seen.base).await
3794 };
3795 let why = format!(
3796 "auto-merge was armed on {} but the pull request did not merge within {} \
3797 minutes ({})",
3798 seen.head,
3799 WAIT_CEILING.as_secs() / 60,
3800 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3801 );
3802 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3803 return Ok(pr);
3804 }
3805 waited += POLL;
3806 forge.poll().await;
3807 continue;
3808 }
3809 if armed_here && !matches!(step, Step::Done { .. }) {
3810 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3813 let why = format!("auto-merge could not be disabled: {e}");
3814 stop(state, &repo, &pr, &why).await?;
3815 return Ok(pr);
3816 }
3817 }
3818 match step {
3819 Step::Wait => {
3820 if waited >= WAIT_CEILING {
3821 let why = format!(
3822 "checks were still running after {} minutes",
3823 WAIT_CEILING.as_secs() / 60
3824 );
3825 stop(state, &repo, &pr, &why).await?;
3826 return Ok(pr);
3827 }
3828 waited += POLL;
3829 forge.poll().await;
3830 }
3831 Step::Done { merged } => {
3832 if let Some(armed) = state.land_armed_head.take() {
3837 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3838 let msg = format!(
3839 "{} merged on {} but the owner approved {armed}; review what landed",
3840 pr.url, seen.head
3841 );
3842 tracing::warn!("{msg}");
3843 state.event("land", msg);
3844 crate::notices::raise_with(
3848 crate::notices::Notice::warn(
3849 &format!("merged-unapproved-head:{}", state.id),
3850 "A pull request merged on a commit the owner did not approve; \
3851 review what landed",
3852 )
3853 .link(crate::notices::Link::Run {
3854 id: state.id.clone(),
3855 }),
3856 &state.config.notify,
3857 );
3858 }
3859 }
3860 state.status = if merged {
3861 RunStatus::Merged
3862 } else {
3863 RunStatus::Ready
3864 };
3865 let detail = if merged {
3866 format!("{} was merged", pr.url)
3867 } else {
3868 format!("{} was closed without merging", pr.url)
3869 };
3870 state.merge = Some(MergeOutcome {
3871 mode: MergeMode::Pr,
3872 ok: merged,
3873 detail: detail.clone(),
3874 empty: false,
3875 });
3876 state.event("land", detail);
3877 state.save()?;
3878 write_pr_state_through(state, pr.state);
3879 return Ok(pr);
3880 }
3881 Step::Merge => {
3882 let subject = merge_subject(
3883 crate::graph::landing_title(state, &seen.title),
3884 &crate::graph::landing_subject_source(state),
3885 );
3886 let contested = contested_to_ask(state);
3893 if state.config.graph.land_approval
3894 || contested.is_some()
3895 || repair_available(state)
3896 || !state.merge_repairs.is_empty()
3897 {
3898 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3899 .await?
3900 {
3901 ApprovalGate::Approved => {}
3902 ApprovalGate::Repair => {
3903 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3906 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3907 state.save()?;
3908 }
3909 return Ok(pr);
3910 }
3911 ApprovalGate::Held => {
3912 if owner_stop(state, &mut pr, forge).await? {
3915 return Ok(pr);
3916 }
3917 stop(
3918 state,
3919 &repo,
3920 &pr,
3921 "the owner did not approve the merge (held or unanswered)",
3922 )
3923 .await?;
3924 return Ok(pr);
3925 }
3926 ApprovalGate::Pending => {
3934 state.end_pass_parked(crate::run::ParkCause::Question);
3935 state.parked = true;
3936 state.event(
3937 "land",
3938 "parked awaiting merge approval - resumes once answered",
3939 );
3940 state.save()?;
3941 return Ok(pr);
3942 }
3943 }
3944 }
3945 let observed_head = seen.head.clone();
3956 {
3959 let fresh = forge.view(&repo, pr_url).await.ok();
3960 if !direct_merge_is_safe(
3961 fresh.as_ref(),
3962 &observed_head,
3963 &shown,
3964 round,
3965 budget,
3966 waited,
3967 ) {
3968 if waited >= WAIT_CEILING {
3969 let why = "the pull request did not settle on the approved head \
3970 before it could be merged";
3971 stop(state, &repo, &pr, why).await?;
3972 return Ok(pr);
3973 }
3974 state.event(
3975 "land",
3976 "the pull request changed before merging; looking again",
3977 );
3978 waited += POLL;
3979 forge.poll().await;
3980 continue;
3981 }
3982 }
3983 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3984 let out = {
3985 let merge_lock = repo_merge_lock(&repo);
3986 let _merge_slot = merge_lock.lock().await;
3987 crate::pr_head::ensure_owner(state)?;
3988 if let Some(task_id) = state
3989 .origin
3990 .as_ref()
3991 .and_then(|origin| origin.task.as_ref())
3992 {
3993 let task = crate::queue::Queue::open()
3994 .get(task_id)
3995 .context("cannot verify the task owner immediately before merge")?;
3996 if task.runs.last() != Some(&state.id)
3997 || task.operator_held()
3998 || task.status == crate::queue::TaskStatus::Done
3999 {
4000 bail!(
4001 "task ownership or an operator hold superseded this merge approval"
4002 );
4003 }
4004 }
4005 if let Some(binding) = &state.land_approval {
4006 let current = ask::Questions::open().get(&binding.question)?;
4007 if merge_consent_superseded(state, ¤t)?
4008 || current.run != state.id
4009 || !binding.head.eq_ignore_ascii_case(&observed_head)
4010 || approval(current.resolution().as_deref()) != Approval::Merge
4011 || current.merge_target.as_ref().is_some_and(|t| {
4012 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4013 })
4014 {
4015 bail!(
4016 "merge decision is stale or no longer authorizes the reviewed head"
4017 );
4018 }
4019 }
4020 forge.merge(&repo, &argv).await?
4021 };
4022 if out.0 {
4023 let confirmed = forge
4029 .view(&repo, pr_url)
4030 .await
4031 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
4032 if !confirmed {
4033 if waited >= WAIT_CEILING {
4034 let why = "the merge request succeeded but the pull request \
4035 could not be confirmed merged after waiting";
4036 stop(state, &repo, &pr, why).await?;
4037 return Ok(pr);
4038 }
4039 state.event(
4040 "land",
4041 "merge accepted but the pull request is not confirmed merged yet; waiting",
4042 );
4043 state.save()?;
4044 waited += POLL;
4045 forge.poll().await;
4046 continue;
4047 }
4048 pr.state = PrLifecycle::Merged;
4049 state.status = RunStatus::Merged;
4050 state.merge = Some(MergeOutcome {
4051 mode: MergeMode::Pr,
4052 ok: true,
4053 detail: format!("gh {}", argv.join(" ")),
4054 empty: false,
4055 });
4056 if let Some(pr_record) = state.pr.as_mut() {
4061 pr_record.state = pr.state.as_str().to_owned();
4062 }
4063 state.event("land", format!("merged {} as `{subject}`", pr.url));
4064 announce_red_merge(state, &pr).await;
4065 state.save()?;
4066 write_pr_state_through(state, pr.state);
4067 return Ok(pr);
4068 }
4069 let after_seen = forge.view(&repo, pr_url).await.ok();
4070 let after = after_seen.as_ref().map(|s| s.pr.state);
4071 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4072 pr.state = PrLifecycle::Merged;
4073 state.status = RunStatus::Merged;
4074 state.merge = Some(outcome);
4075 if let Some(pr_record) = state.pr.as_mut() {
4076 pr_record.state = pr.state.as_str().to_owned();
4077 }
4078 state.event("land", format!("merged {} as `{subject}`", pr.url));
4079 announce_red_merge(state, &pr).await;
4080 state.save()?;
4081 write_pr_state_through(state, pr.state);
4082 return Ok(pr);
4083 }
4084 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4085 match verdict {
4086 Refused::Final => {
4087 let merge_state = after_seen
4088 .as_ref()
4089 .map(|s| s.merge_state.as_str())
4090 .filter(|m| !m.is_empty())
4091 .unwrap_or("unknown");
4092 let why = format!(
4096 "the merge was refused: {} (merge state: {merge_state})",
4097 out.1
4098 );
4099 stop(state, &repo, &pr, &why).await?;
4100 return Ok(pr);
4101 }
4102 verdict => {
4103 if waited >= WAIT_CEILING {
4108 let why = format!(
4109 "the merge was still refused after {} minutes: {}",
4110 WAIT_CEILING.as_secs() / 60,
4111 out.1
4112 );
4113 stop(state, &repo, &pr, &why).await?;
4114 return Ok(pr);
4115 }
4116 if verdict == Refused::Recheck {
4117 rechecked = true;
4118 }
4119 state.event(
4120 "land",
4121 "merge refused while the branch policy is not satisfied yet; waiting",
4122 );
4123 state.save()?;
4124 waited += POLL;
4125 forge.poll().await;
4126 }
4127 }
4128 }
4129 Step::Rebase => {
4130 if rebases >= budget {
4136 let why = format!(
4137 "the base moved under this branch {budget} time(s) and it still does \
4138 not merge; rebasing again would only race it"
4139 );
4140 stop(state, &repo, &pr, &why).await?;
4141 return Ok(pr);
4142 }
4143 rebases += 1;
4144 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4145 stop(
4146 state,
4147 &repo,
4148 &pr,
4149 "the pull request conflicts and this run has no winning branch to rebase",
4150 )
4151 .await?;
4152 return Ok(pr);
4153 };
4154 let base = state.base_branch.clone();
4155 state.event(
4156 "land",
4157 format!("{} no longer merges; rebasing onto {base}", pr.url),
4158 );
4159 state.save()?;
4160
4161 let remote = state.config.merge.remote.clone();
4165 git::fetch(&repo, &remote, &base).await.ok();
4166 if owner_stop(state, &mut pr, forge).await? {
4167 return Ok(pr);
4168 }
4169 let scratch = state.dir().join("rebase");
4170 let onto = format!("{remote}/{base}");
4171 let rebased =
4172 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4173 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4174 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4175 Err(e) => Err(e),
4176 };
4177 if owner_stop(state, &mut pr, forge).await? {
4178 return Ok(pr);
4179 }
4180 match rebased {
4181 Ok(None) => {
4182 let pushed = {
4183 let merge_lock = repo_merge_lock(&repo);
4184 let _merge_slot = merge_lock.lock().await;
4185 if let Some(head) = &state.pr_head {
4186 git::push_as(
4187 &repo,
4188 &state.config.merge.remote,
4189 &branch,
4190 head,
4191 state
4192 .pr_pushed_head
4193 .as_deref()
4194 .context("missing public head lease")?,
4195 )
4196 .await?
4197 } else {
4198 git::push_pinned(
4199 &repo,
4200 &state.config.merge.remote,
4201 &branch,
4202 &seen.head,
4203 )
4204 .await?
4205 }
4206 };
4207 if !pushed.ok() {
4208 let why = format!(
4209 "rebased {branch} but could not push it: {}",
4210 pushed.stderr.trim()
4211 );
4212 stop(state, &repo, &pr, &why).await?;
4213 return Ok(pr);
4214 }
4215 let head =
4218 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4219 Ok(head) => head,
4220 Err(e) => {
4221 let why = format!(
4222 "rebased and pushed {branch} but could not read the pushed \
4223 commit: {e:#}"
4224 );
4225 stop(state, &repo, &pr, &why).await?;
4226 return Ok(pr);
4227 }
4228 };
4229 crate::graph::refresh_reviewed_commits(state, &branch).await;
4230 state.pr_pushed_head = Some(head.clone());
4231 awaiting_head = Some(head);
4232 rechecked = false;
4233 state.event("land", format!("rebased {branch} onto {base}"));
4234 state.save()?;
4235 waited = Duration::ZERO;
4238 tokio::time::sleep(POLL).await;
4239 }
4240 Ok(Some(conflict)) => {
4243 let why = format!(
4244 "{} conflicts with {base} and the rebase did not apply: {}",
4245 pr.url,
4246 conflict.chars().take(600).collect::<String>()
4247 );
4248 stop(state, &repo, &pr, &why).await?;
4249 return Ok(pr);
4250 }
4251 Err(e) => {
4252 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4253 stop(state, &repo, &pr, &why).await?;
4254 return Ok(pr);
4255 }
4256 }
4257 }
4258 Step::GiveUp { reason } => {
4259 stop(state, &repo, &pr, &reason).await?;
4260 return Ok(pr);
4261 }
4262 Step::Fix { reason } => {
4263 round += 1;
4264 waited = Duration::ZERO;
4265 for c in &pr.review_comments {
4266 shown.insert(c.body.clone());
4267 }
4268 state.event("land", format!("round {round}: {reason}"));
4269 state.save()?;
4270
4271 let logs = failing_logs(&repo, &seen.failing_urls).await;
4272 let was_red = pr.checks == Checks::Red;
4273 if owner_stop(state, &mut pr, forge).await? {
4274 return Ok(pr);
4275 }
4276 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4277 if owner_stop(state, &mut pr, forge).await? {
4278 return Ok(pr);
4279 }
4280 match fixed {
4281 Fixed::Committed { head } => {
4282 state.pr_pushed_head = Some(head.clone());
4285 awaiting_head = Some(head);
4286 rechecked = false;
4287 waited = Duration::ZERO;
4288 forge.poll().await;
4289 }
4290 Fixed::Declined if was_red => {
4291 let why = format!(
4292 "the fixer produced no commit while {} check(s) were failing \
4293 ({}); stopping instead of looping on an unchanged tree",
4294 pr.failing.len(),
4295 pr.failing.join(", ")
4296 );
4297 stop(state, &repo, &pr, &why).await?;
4298 return Ok(pr);
4299 }
4300 Fixed::Declined => state.event(
4305 "land",
4306 format!("round {round}: fixer declined the comments, nothing committed"),
4307 ),
4308 Fixed::Failed(why) => {
4309 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4310 return Ok(pr);
4311 }
4312 }
4313 state.save()?;
4314 }
4315 }
4316 }
4317}
4318
4319#[derive(Clone)]
4323struct Seen {
4324 pr: PrState,
4325 title: String,
4326 failing_urls: Vec<(String, String)>,
4327 head: String,
4329 rollup_head: String,
4332 merge_state: String,
4335 contexts: Vec<CheckInfo>,
4337 base: String,
4339}
4340
4341#[derive(Clone)]
4343struct CheckInfo {
4344 label: String,
4345 verdict: Verdict,
4346 required: Option<bool>,
4347 run: Option<String>,
4349}
4350
4351#[derive(Debug, Clone, PartialEq, Eq)]
4353enum RerunStep {
4354 Rerun(Vec<String>),
4356 Grace,
4358 Fix,
4360}
4361
4362fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4366 contexts
4367 .iter()
4368 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4369 .filter(|c| {
4371 c.run.as_ref().is_none_or(|r| {
4372 !contexts.iter().any(|o| {
4373 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4374 && o.run.as_ref() == Some(r)
4375 })
4376 })
4377 })
4378 .map(|c| (c.label.clone(), c.run.clone()))
4379 .collect()
4380}
4381
4382fn rerun_step(
4384 failing: &[(String, Option<String>)],
4385 record: Option<&LandReruns>,
4386 head: &str,
4387 now: i64,
4388) -> RerunStep {
4389 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4392 return RerunStep::Fix;
4393 }
4394 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4396 let mut fresh: Vec<String> = Vec::new();
4397 let mut recent = false;
4398 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4399 match record.and_then(|r| r.runs.get(run)) {
4400 None => {
4401 if !fresh.iter().any(|f| f == run) {
4402 fresh.push(run.to_owned());
4403 }
4404 }
4405 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4406 Some(_) => {}
4407 }
4408 }
4409 if !fresh.is_empty() {
4410 RerunStep::Rerun(fresh)
4411 } else if recent {
4412 RerunStep::Grace
4413 } else {
4414 RerunStep::Fix
4415 }
4416}
4417
4418async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4422 let view = gh(
4423 repo,
4424 &[
4425 "pr".to_owned(),
4426 "view".to_owned(),
4427 pr_url.to_owned(),
4428 "--json".to_owned(),
4429 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4430 .to_owned(),
4431 ],
4432 )
4433 .await?;
4434 if !view.0 {
4435 bail!("gh pr view {pr_url}: {}", view.1);
4436 }
4437 let number = parse_pr(&view.1)?.number;
4438 let node = last_commit_node(repo, number).await;
4439 let mut seen = seen_from(&view.1, node.as_deref())?;
4440
4441 let inline = gh(
4442 repo,
4443 &[
4444 "api".to_owned(),
4445 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4446 ],
4447 )
4448 .await?;
4449 if inline.0 {
4450 match parse_inline_comments(&inline.1) {
4451 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4452 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4455 }
4456 } else {
4457 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4458 }
4459 Ok(seen)
4460}
4461
4462fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4473 let mut pr = parse_pr(view_json)?;
4474 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4475
4476 let mut rollup_head = String::new();
4477 let mut failing_urls = Vec::new();
4478 let mut contexts = Vec::new();
4479 let mut checks = Checks::Unknown;
4480 let mut failing = Vec::new();
4481 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4482 (checks, failing) = rollup_verdict(&rollup);
4483 failing_urls = rollup
4484 .iter()
4485 .filter(|c| c.verdict() == Verdict::Fail)
4486 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4487 .collect();
4488 contexts = rollup
4489 .iter()
4490 .map(|c| CheckInfo {
4491 label: c.label(),
4492 verdict: c.verdict(),
4493 required: c.is_required,
4494 run: c.url().and_then(run_of),
4495 })
4496 .collect();
4497 rollup_head = oid;
4498 }
4499 pr.checks = checks;
4500 pr.failing = failing;
4501
4502 Ok(Seen {
4503 pr,
4504 title: raw.title,
4505 failing_urls,
4506 head: raw.head_ref_oid,
4507 rollup_head,
4508 merge_state: raw.merge_state_status,
4509 contexts,
4510 base: raw.base_ref_name,
4511 })
4512}
4513
4514fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4517 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4518 if v.get("errors").is_some_and(|e| !e.is_null()) {
4519 return None;
4520 }
4521 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4522 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4523 let contexts = commit.pointer("/statusCheckRollup/contexts");
4524 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4525 return Some((oid.to_owned(), Vec::new()));
4527 };
4528 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4529 return None;
4530 }
4531 let nodes = contexts.get("nodes")?.as_array()?;
4532 let rollup = nodes
4533 .iter()
4534 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4535 .collect::<Result<Vec<_>, _>>()
4536 .ok()?;
4537 Some((oid.to_owned(), rollup))
4538}
4539
4540async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4543 let out = gh(
4544 repo,
4545 &[
4546 "api".to_owned(),
4547 "graphql".to_owned(),
4548 "-F".to_owned(),
4549 "owner={owner}".to_owned(),
4550 "-F".to_owned(),
4551 "repo={repo}".to_owned(),
4552 "-F".to_owned(),
4553 format!("number={number}"),
4554 "-f".to_owned(),
4555 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4556 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4557 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4558 ... on CheckRun{name status conclusion detailsUrl \
4559 isRequired(pullRequestNumber:$number)} \
4560 ... on StatusContext{context state targetUrl \
4561 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4562 .to_owned(),
4563 ],
4564 )
4565 .await
4566 .ok()?;
4567 out.0.then_some(out.1)
4568}
4569
4570#[doc(hidden)]
4572#[derive(Debug, PartialEq)]
4573pub enum Fixed {
4574 Committed {
4576 head: String,
4578 },
4579 Declined,
4581 Failed(String),
4583}
4584
4585#[doc(hidden)]
4591pub async fn fix_round(
4592 state: &mut RunState,
4593 pr: &PrState,
4594 round: usize,
4595 budget: usize,
4596 reason: &str,
4597 logs: &str,
4598) -> Result<Fixed> {
4599 let winner = state
4600 .winner()
4601 .cloned()
4602 .context("landing needs a winning candidate; none is recorded on this run")?;
4603 let roles = state
4604 .config
4605 .resolve_roles()
4606 .context("resolve the roster for the fix round")?;
4607 let attempts = crate::fixer::attempts(state, &roles, &winner);
4611 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4612
4613 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4614 let mut artifacts = agent::artifacts_dir(&state.dir());
4615 if let Some(request) = state
4616 .merge_repairs
4617 .last()
4618 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4619 {
4620 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4621 }
4622 let prompt = if state.config.cache_dir().is_some() {
4623 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4624 } else {
4625 prompt
4626 };
4627 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4631 let mut last = None;
4635 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4636 if owner_requested_stop(state)? {
4637 return Ok(Fixed::Declined);
4638 }
4639 let mut seat = seat_of(state, &seat_key, &spec.id);
4640 let stem = if i == 0 {
4641 format!("land-{round}")
4642 } else {
4643 format!("land-{round}-{}", spec.id)
4644 };
4645 let out = agent::invoke(
4646 &spec,
4647 &mut seat,
4648 &Invocation {
4649 scope: crate::agent::InvocationScope::Repository,
4650 coordination: state.config.coordination.as_ref(),
4651 fetched_base: state.fetched_base.as_ref(),
4652 cwd: &winner.worktree,
4653 prompt: &prompt,
4654 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4655 allow_write: true,
4656 unsandboxed: false,
4657 sessions: state.config.graph.sessions,
4658 artifacts: &artifacts,
4659 stem: &stem,
4660 run: &state.id,
4661 node: "land",
4662 cache_dir: state.config.cache_dir().as_deref(),
4663 attachments: &[],
4664 writable: &[],
4665 },
4666 )
4667 .await;
4668 state.seats.insert(seat.key.clone(), seat);
4669 if let Some(next) = ids.get(i + 1)
4670 && agent::chain_advances(&out)
4671 {
4672 let (class, why) = crate::fixer::failure_of(&out);
4673 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4674 continue;
4675 }
4676 last = Some(out);
4677 break;
4678 }
4679 let out = last.expect("the fixer chain always has an entry");
4680
4681 match out {
4682 Ok(o) if o.quota_exhausted() => {
4683 return Ok(Fixed::Failed(
4684 "rate limited (quota); the fixer could not run".to_owned(),
4685 ));
4686 }
4687 Ok(o) if !o.usable() => {
4688 return Ok(Fixed::Failed(format!(
4689 "the fixer produced nothing usable ({})",
4690 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
4691 )));
4692 }
4693 Ok(_) => {}
4694 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4695 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4696 }
4697
4698 if owner_requested_stop(state)? {
4699 return Ok(Fixed::Declined);
4700 }
4701
4702 let wait = crate::repo_lock::WaitPolicy::new(
4705 Duration::from_secs(state.config.graph.timeout_resource_wait),
4706 || false,
4707 );
4708 let coordination = crate::repo_lock::SeatLease::operation_with(
4709 &winner.worktree,
4710 state.config.coordination.as_ref(),
4711 &wait,
4712 )
4713 .await?;
4714 match git::rescue_commit(
4715 &winner.worktree,
4716 &format!("magi: land round {round} fixes (uncommitted work)"),
4717 )
4718 .await
4719 {
4720 Ok(r) => state.note_withheld("land", &r.withheld),
4721 Err(e)
4722 if state
4723 .merge_repairs
4724 .last()
4725 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4726 {
4727 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4728 }
4729 Err(_) => {}
4730 }
4731 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4732 drop(coordination);
4733 if after == before {
4734 return Ok(Fixed::Declined);
4735 }
4736
4737 if owner_requested_stop(state)? {
4738 return Ok(Fixed::Declined);
4739 }
4740 crate::pr_head::ensure_owner(state)?;
4741 let remote = state.config.merge.remote.clone();
4742 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4743 let push = if state.pr_head.is_some() {
4744 let expected = state
4745 .pr_pushed_head
4746 .as_deref()
4747 .context("missing public head lease")?;
4748 git::push_as(
4749 &winner.worktree,
4750 &remote,
4751 &winner.branch,
4752 &public_head,
4753 expected,
4754 )
4755 .await?
4756 } else {
4757 git::push(&winner.worktree, &remote, &winner.branch).await?
4758 };
4759 if !push.ok() {
4760 return Ok(Fixed::Failed(format!(
4761 "pushing {} to {remote} failed: {}",
4762 public_head, push.stderr
4763 )));
4764 }
4765 state.event(
4766 "land",
4767 format!("round {round}: pushed a fix to {public_head}"),
4768 );
4769 state.pr_pushed_head = Some(after.clone());
4770 state.save()?;
4771 Ok(Fixed::Committed { head: after })
4772}
4773
4774pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4776 crate::fixer::seat_for(state, key, agent)
4777}
4778
4779pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4782 let seen = observe(&state.repo, url).await?;
4783 let winner = state.winner().context("no winning branch to repair")?;
4784 if seen.pr.state != PrLifecycle::Open || seen.head != head {
4785 anyhow::bail!(
4786 "PR is closed or its head changed; review the current head before requesting repair again"
4787 );
4788 }
4789 if git::rev_parse(&winner.worktree, "HEAD").await? != head
4790 || !git::is_clean(&winner.worktree).await?
4791 {
4792 anyhow::bail!(
4793 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4794 );
4795 }
4796 let view = gh(
4799 &state.repo,
4800 &[
4801 "pr".into(),
4802 "view".into(),
4803 url.into(),
4804 "--json".into(),
4805 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4806 ],
4807 )
4808 .await?;
4809 if !view.0 {
4810 bail!("cannot check auto-merge: {}", view.1);
4811 }
4812 let live: serde_json::Value = serde_json::from_str(&view.1)?;
4813 let public = state.pr_branch().unwrap_or(&winner.branch);
4814 if live["state"] != "OPEN"
4815 || live["headRefOid"] != head
4816 || live["headRefName"] != public
4817 || live["baseRefName"] != state.base_branch
4818 || live["isCrossRepository"] != false
4819 {
4820 bail!("PR changed during repair preflight; review its current head explicitly");
4821 }
4822 let auto_merge = live
4823 .get("autoMergeRequest")
4824 .context("cannot read the PR auto-merge reservation; repair held")?;
4825 if !auto_merge.is_null() {
4826 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4827 if !out.0 {
4828 bail!("cannot disable auto-merge; repair held: {}", out.1);
4829 }
4830 }
4831 Ok(seen.pr)
4832}
4833
4834fn fix_prompt(
4836 state: &RunState,
4837 pr: &PrState,
4838 round: usize,
4839 budget: usize,
4840 reason: &str,
4841 logs: &str,
4842) -> String {
4843 let mut s = format!(
4844 "Your patch is open as a pull request and it is not landing. Land round \
4845 {round} of {budget}.\n\n\
4846 Pull request: {}\n\n\
4847 What is holding it: {reason}\n\n\
4848 # The task\n\n{}\n",
4849 pr.url, state.instruction
4850 );
4851
4852 if pr.failing.is_empty() {
4853 s.push_str("\n# Failing checks\n\n(none)\n");
4854 } else {
4855 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4856 if logs.trim().is_empty() {
4857 s.push_str(
4858 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
4859 );
4860 } else {
4861 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4862 }
4863 }
4864
4865 if pr.review_comments.is_empty() {
4866 s.push_str("\n# Review comments\n\n(none)\n");
4867 } else {
4868 s.push_str("\n# Review comments\n");
4869 for c in &pr.review_comments {
4870 let where_ = match (&c.path, c.line) {
4871 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4872 (Some(p), None) => format!(" ({p})"),
4873 _ => String::new(),
4874 };
4875 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4876 }
4877 }
4878
4879 s.push_str(
4880 "\n# Rules\n\n\
4881 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4882 failing test; do not silence a lint with an allow attribute; do not \
4883 stretch a timeout to hide a race. If the check is right, the code is \
4884 wrong.\n\
4885 2. Change nothing the checks and the comments did not raise. A \
4886 drive-by refactor turns a one-line fix into a pull request that \
4887 needs reviewing again.\n\
4888 3. If a comment is wrong, say so with a checkable argument and change \
4889 nothing for it. A declined comment with a reason is a correct \
4890 outcome; a change made to appease a reviewer is not.\n\
4891 4. Commit in this worktree. magi pushes to the pull request's branch \
4892 for you; do not push, merge, or close anything yourself.\n\
4893 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4894 # Output\n\n\
4895 Say what you changed and why, and what you declined and why.",
4896 );
4897
4898 let language = &state.config.graph.language;
4899 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4900 let _ = write!(s, "\n\nWrite all prose in {language}.");
4901 }
4902 s.push_str(&crate::prompt::github_rule(
4904 language,
4905 &state.config.graph.github_language,
4906 ));
4907 if let Some(overlay) = state.config.prompts.overlay("fix") {
4908 let _ = write!(s, "\n\n{overlay}");
4909 }
4910 s.push('\n');
4911 s.push_str(crate::prompt::child_run_ownership());
4912 s
4913}
4914
4915async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4918 let mut out = String::new();
4919 for (name, url) in failing.iter().take(MAX_LOGS) {
4920 let args = match (job_of(url), run_of(url)) {
4921 (Some(job), _) => vec![
4922 "run".to_owned(),
4923 "view".to_owned(),
4924 "--log-failed".to_owned(),
4925 "--job".to_owned(),
4926 job,
4927 ],
4928 (None, Some(run)) => vec![
4929 "run".to_owned(),
4930 "view".to_owned(),
4931 run,
4932 "--log-failed".to_owned(),
4933 ],
4934 (None, None) => continue,
4936 };
4937 let (ok, body) = match gh(repo, &args).await {
4938 Ok(v) => v,
4939 Err(e) => (false, format!("{e:#}")),
4940 };
4941 if !ok && body.trim().is_empty() {
4942 continue;
4943 }
4944 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4945 }
4946 out
4947}
4948
4949fn job_of(details_url: &str) -> Option<String> {
4952 let after = details_url.split("/job/").nth(1)?;
4953 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4954 (!id.is_empty()).then_some(id)
4955}
4956
4957pub(crate) fn run_of(details_url: &str) -> Option<String> {
4959 let after = details_url.split("/actions/runs/").nth(1)?;
4960 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4961 (!id.is_empty()).then_some(id)
4962}
4963
4964fn stop_comment(run_id: &str, why: &str) -> String {
4968 format!(
4969 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4970 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4971 )
4972}
4973
4974async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4979 let body = stop_comment(&state.id, why);
4980 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4981 if !body.contains(MARKER) {
4982 body = format!("{MARKER}\n{body}");
4983 }
4984 let posted = gh(
4985 repo,
4986 &[
4987 "pr".to_owned(),
4988 "comment".to_owned(),
4989 pr.number.to_string(),
4990 "--body".to_owned(),
4991 body,
4992 ],
4993 )
4994 .await;
4995 match posted {
4996 Ok((true, _)) => {}
4997 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4998 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4999 }
5000 state.status = RunStatus::Blocked;
5001 state.merge = Some(MergeOutcome {
5002 mode: MergeMode::Pr,
5003 ok: false,
5004 detail: why.to_owned(),
5005 empty: false,
5006 });
5007 state.event("land", format!("stopped: {why}"));
5008 state.save()?;
5009 Ok(())
5010}
5011
5012pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5028 let out = tokio::process::Command::new("gh")
5029 .args(args)
5030 .current_dir(cwd)
5031 .env_remove("GH_REPO")
5032 .quiet()
5033 .stdin(std::process::Stdio::null())
5034 .output()
5035 .await
5036 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5037 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5038 let err = String::from_utf8_lossy(&out.stderr);
5039 if body.trim().is_empty() {
5040 body = err.into_owned();
5041 } else if !err.trim().is_empty() {
5042 body.push_str(&err);
5043 }
5044 Ok((out.status.success(), body.trim().to_owned()))
5045}
5046
5047#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5049pub(crate) enum Verdict {
5050 Pass,
5051 Fail,
5052 Pending,
5053 Unknown,
5054}
5055
5056impl Verdict {
5057 pub(crate) fn label(self) -> &'static str {
5059 match self {
5060 Self::Pass => "passed",
5061 Self::Fail => "failed",
5062 Self::Pending => "pending",
5063 Self::Unknown => "unknown",
5064 }
5065 }
5066}
5067
5068#[derive(Debug, Deserialize)]
5069#[serde(rename_all = "camelCase")]
5070struct GhPr {
5071 #[serde(default)]
5072 url: String,
5073 #[serde(default)]
5074 number: u64,
5075 #[serde(default)]
5076 state: String,
5077 #[serde(default)]
5078 title: String,
5079 #[serde(default)]
5080 status_check_rollup: Vec<GhCheck>,
5081 #[serde(default)]
5088 merge_state_status: String,
5089 #[serde(default)]
5093 head_ref_oid: String,
5094 #[serde(default)]
5095 base_ref_name: String,
5096 #[serde(default)]
5097 reviews: Vec<GhReview>,
5098 #[serde(default)]
5099 comments: Vec<GhComment>,
5100}
5101
5102#[derive(Debug, Deserialize)]
5107#[serde(rename_all = "camelCase")]
5108struct GhCheck {
5109 #[serde(default)]
5110 name: Option<String>,
5111 #[serde(default)]
5112 context: Option<String>,
5113 #[serde(default)]
5114 status: Option<String>,
5115 #[serde(default)]
5116 conclusion: Option<String>,
5117 #[serde(default)]
5118 state: Option<String>,
5119 #[serde(default)]
5120 details_url: Option<String>,
5121 #[serde(default)]
5122 target_url: Option<String>,
5123 #[serde(default)]
5126 is_required: Option<bool>,
5127}
5128
5129impl GhCheck {
5130 fn label(&self) -> String {
5132 self.name
5133 .clone()
5134 .or_else(|| self.context.clone())
5135 .unwrap_or_else(|| "(unnamed check)".to_owned())
5136 }
5137
5138 fn url(&self) -> Option<&str> {
5140 self.details_url
5141 .as_deref()
5142 .or(self.target_url.as_deref())
5143 .filter(|u| !u.is_empty())
5144 }
5145
5146 fn verdict(&self) -> Verdict {
5154 if let Some(status) = self.status.as_deref() {
5155 if !status.eq_ignore_ascii_case("COMPLETED") {
5156 return Verdict::Pending;
5157 }
5158 }
5159 let outcome = self
5160 .conclusion
5161 .as_deref()
5162 .or(self.state.as_deref())
5163 .unwrap_or("");
5164 match outcome.to_ascii_uppercase().as_str() {
5165 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5166 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5167 | "ACTION_REQUIRED" => Verdict::Fail,
5168 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5169 Verdict::Pending
5170 }
5171 _ => Verdict::Unknown,
5172 }
5173 }
5174}
5175
5176#[derive(Debug, Deserialize)]
5177struct GhAuthor {
5178 #[serde(default)]
5179 login: String,
5180}
5181
5182#[derive(Debug, Deserialize)]
5183struct GhReview {
5184 #[serde(default)]
5185 author: GhAuthor,
5186 #[serde(default)]
5187 body: String,
5188}
5189
5190#[derive(Debug, Deserialize)]
5191struct GhComment {
5192 #[serde(default)]
5193 author: GhAuthor,
5194 #[serde(default)]
5195 body: String,
5196}
5197
5198#[derive(Debug, Deserialize)]
5199struct GhUser {
5200 #[serde(default)]
5201 login: String,
5202}
5203
5204#[derive(Debug, Deserialize)]
5205struct GhInline {
5206 #[serde(default)]
5207 user: GhUser,
5208 #[serde(default)]
5209 path: Option<String>,
5210 #[serde(default)]
5211 line: Option<u64>,
5212 #[serde(default)]
5213 body: String,
5214}
5215
5216impl Default for GhAuthor {
5217 fn default() -> Self {
5218 Self {
5219 login: "(unknown)".to_owned(),
5220 }
5221 }
5222}
5223
5224impl Default for GhUser {
5225 fn default() -> Self {
5226 Self {
5227 login: "(unknown)".to_owned(),
5228 }
5229 }
5230}
5231
5232#[cfg(test)]
5233pub(crate) mod tests {
5234 use super::*;
5235 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5236
5237 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5238 format!(
5239 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5240 )
5241 }
5242
5243 #[test]
5244 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5245 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5246 assert_eq!(
5247 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5248 Ok(())
5249 );
5250 for (json, why) in [
5251 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5252 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5253 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5254 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5255 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5256 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5257 ] {
5258 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5259 .unwrap_err()
5260 .why;
5261 assert!(err.contains(why), "{json}: {err}");
5262 }
5263 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5265 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5266 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5267 assert!(
5268 !closable(
5269 &head_json("x", "main", "OPEN", false),
5270 "magi/27b2/A",
5271 "main",
5272 &[]
5273 )
5274 .unwrap_err()
5275 .retry
5276 );
5277 assert!(is_forge_url("https://github.com/o/r.git"));
5278 assert!(is_forge_url("git@github.com:o/r.git"));
5279 assert!(!is_forge_url("/tmp/origin.git"));
5280 assert!(!is_forge_url("C:\\work\\origin.git"));
5281 assert!(!is_forge_url("file:///tmp/origin.git"));
5282 assert!(forge_unavailable(
5283 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5284 ));
5285 assert!(!forge_unavailable(
5286 "gh pr list failed: error connecting to api.github.com"
5287 ));
5288 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5289 assert!(
5291 closable(
5292 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5293 "b",
5294 "main",
5295 &["aaa".to_owned()]
5296 )
5297 .is_err()
5298 );
5299 }
5300
5301 #[test]
5302 fn the_close_comment_names_the_commit_on_the_base() {
5303 let e = crate::already::Evidence {
5304 proof: crate::already::Proof::PatchId,
5305 tip: "1234567890".to_owned(),
5306 commits: vec!["0e368de0000".to_owned()],
5307 };
5308 let c = superseded_comment("main", &e);
5309 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5310 }
5311
5312 const GREEN_OPEN: &str = r####"{
5314 "url": "https://github.com/yukimemi/magi/pull/10",
5315 "number": 10,
5316 "state": "OPEN",
5317 "mergeStateStatus": "CLEAN",
5318 "statusCheckRollup": [
5319 {
5320 "__typename": "CheckRun",
5321 "conclusion": "SKIPPED",
5322 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5323 "name": "review",
5324 "status": "COMPLETED",
5325 "workflowName": "claude-review"
5326 },
5327 {
5328 "__typename": "CheckRun",
5329 "conclusion": "SUCCESS",
5330 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5331 "name": "check (ubuntu-latest)",
5332 "status": "COMPLETED",
5333 "workflowName": "CI"
5334 },
5335 {
5336 "__typename": "CheckRun",
5337 "conclusion": "SUCCESS",
5338 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5339 "name": "rustfmt",
5340 "status": "COMPLETED",
5341 "workflowName": "CI"
5342 },
5343 {
5344 "__typename": "StatusContext",
5345 "context": "CodeRabbit",
5346 "state": "SUCCESS",
5347 "targetUrl": ""
5348 }
5349 ],
5350 "reviews": [],
5351 "comments": [
5352 {
5353 "author": {
5354 "login": "coderabbitai"
5355 },
5356 "authorAssociation": "NONE",
5357 "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"
5358 }
5359 ]
5360}"####;
5361
5362 const RED_OPEN: &str = r####"{
5364 "url": "https://github.com/yukimemi/magi/pull/9",
5365 "number": 9,
5366 "state": "OPEN",
5367 "mergeStateStatus": "UNSTABLE",
5368 "statusCheckRollup": [
5369 {
5370 "__typename": "CheckRun",
5371 "conclusion": "SUCCESS",
5372 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5373 "name": "check (ubuntu-latest)",
5374 "status": "COMPLETED",
5375 "workflowName": "CI"
5376 },
5377 {
5378 "__typename": "CheckRun",
5379 "conclusion": "SUCCESS",
5380 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5381 "name": "rustfmt",
5382 "status": "COMPLETED",
5383 "workflowName": "CI"
5384 },
5385 {
5386 "__typename": "CheckRun",
5387 "conclusion": "FAILURE",
5388 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5389 "name": "editorconfig",
5390 "status": "COMPLETED",
5391 "workflowName": "CI"
5392 },
5393 {
5394 "__typename": "StatusContext",
5395 "context": "CodeRabbit",
5396 "state": "SUCCESS",
5397 "targetUrl": ""
5398 }
5399 ],
5400 "reviews": [],
5401 "comments": [
5402 {
5403 "author": {
5404 "login": "coderabbitai"
5405 },
5406 "authorAssociation": "NONE",
5407 "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"
5408 }
5409 ]
5410}"####;
5411
5412 const PENDING_OPEN: &str = r####"{
5414 "url": "https://github.com/yukimemi/magi/pull/9",
5415 "number": 9,
5416 "state": "OPEN",
5417 "statusCheckRollup": [
5418 {
5419 "__typename": "CheckRun",
5420 "conclusion": "SUCCESS",
5421 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5422 "name": "check (ubuntu-latest)",
5423 "status": "COMPLETED",
5424 "workflowName": "CI"
5425 },
5426 {
5427 "__typename": "CheckRun",
5428 "conclusion": "SUCCESS",
5429 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5430 "name": "rustfmt",
5431 "status": "COMPLETED",
5432 "workflowName": "CI"
5433 },
5434 {
5435 "__typename": "CheckRun",
5436 "conclusion": null,
5437 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5438 "name": "editorconfig",
5439 "status": "IN_PROGRESS",
5440 "workflowName": "CI"
5441 },
5442 {
5443 "__typename": "StatusContext",
5444 "context": "CodeRabbit",
5445 "state": "SUCCESS",
5446 "targetUrl": ""
5447 }
5448 ],
5449 "reviews": [],
5450 "comments": []
5451}"####;
5452
5453 const MERGED: &str = r####"{
5455 "url": "https://github.com/yukimemi/magi/pull/16",
5456 "number": 16,
5457 "state": "MERGED",
5458 "statusCheckRollup": [
5459 {
5460 "__typename": "CheckRun",
5461 "conclusion": "SUCCESS",
5462 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5463 "name": "check (ubuntu-latest)",
5464 "status": "COMPLETED",
5465 "workflowName": "CI"
5466 },
5467 {
5468 "__typename": "CheckRun",
5469 "conclusion": "SUCCESS",
5470 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5471 "name": "review",
5472 "status": "COMPLETED",
5473 "workflowName": "claude-review"
5474 }
5475 ],
5476 "reviews": [],
5477 "comments": []
5478}"####;
5479
5480 const REVIEWED_OPEN: &str = r####"{
5482 "url": "https://github.com/yukimemi/magi/pull/12",
5483 "number": 12,
5484 "state": "OPEN",
5485 "statusCheckRollup": [
5486 {
5487 "__typename": "CheckRun",
5488 "conclusion": "SUCCESS",
5489 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5490 "name": "check (ubuntu-latest)",
5491 "status": "COMPLETED",
5492 "workflowName": "CI"
5493 },
5494 {
5495 "__typename": "CheckRun",
5496 "conclusion": "SUCCESS",
5497 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5498 "name": "review",
5499 "status": "COMPLETED",
5500 "workflowName": "claude-review"
5501 }
5502 ],
5503 "reviews": [
5504 {
5505 "author": {
5506 "login": "claude"
5507 },
5508 "state": "COMMENTED",
5509 "body": ""
5510 }
5511 ],
5512 "comments": [
5513 {
5514 "author": {
5515 "login": "coderabbitai"
5516 },
5517 "authorAssociation": "NONE",
5518 "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"
5519 },
5520 {
5521 "author": {
5522 "login": "claude"
5523 },
5524 "authorAssociation": "NONE",
5525 "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"
5526 }
5527 ]
5528}"####;
5529
5530 const INLINE: &str = r####"[
5532 {
5533 "user": {
5534 "login": "claude[bot]"
5535 },
5536 "path": "src/graph.rs",
5537 "line": 231,
5538 "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"
5539 }
5540]"####;
5541
5542 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5544<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5545
5546> [!IMPORTANT]
5547> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5548>
5549> This repository does not receive automatic reviews because it has fewer than 10 stars.
5550>
5551> <details>
5552> <summary>⚙️ Run configuration</summary>
5553>
5554> **Configuration used**: defaults
5555>
5556> **Review profile**: CHILL
5557>
5558> **Plan**: Team
5559>
5560> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5561>
5562> </details>
5563
5564<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5565
5566<!-- tips_start -->
5567
5568---
5569
5570Thanks 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.
5571
5572<details>
5573<summary>❤️ Share</summary>
5574
5575- [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"####;
5576
5577 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5579
5580---
5581### Reviewing PR #16
5582
5583- [x] Read AGENTS.md conventions
5584- [x] Review `src/daemon.rs` changes
5585- [x] Review `src/main.rs` changes (new `doctor` reporting)
5586- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5587- [x] Check test coverage for new behavior
5588- [x] Run verification commands (blocked — see note)
5589- [x] Post findings"####;
5590
5591 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5593
5594---
5595### Review: `magi review <branch>` — cheap-half-only graph
5596
5597Read 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.
5598
5599**Correctness**
5600
5601- 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"####;
5602
5603 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5604 PrState {
5605 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5606 number: 16,
5607 state: PrLifecycle::Open,
5608 checks,
5609 blocking: if matches!(checks, Checks::Red) {
5613 Blocking::Yes
5614 } else {
5615 Blocking::No
5616 },
5617 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5618 review_comments: (0..comments)
5619 .map(|i| ReviewComment {
5620 author: "coderabbitai".to_owned(),
5621 path: Some("src/graph.rs".to_owned()),
5622 line: Some(231),
5623 body: format!("finding {i}"),
5624 })
5625 .collect(),
5626 }
5627 }
5628
5629 #[test]
5630 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5631 use CiExpectation::{Absent, Expected};
5632 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5633 let p = pr(checks, &["x"], 0);
5634 for waited in [Duration::ZERO, CHECKS_GRACE] {
5635 assert_eq!(
5636 decide_with(&p, 0, 4, waited, Expected),
5637 decide(&p, 0, 4, waited)
5638 );
5639 }
5640 }
5641 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5643 let p = pr(checks, &["x"], 0);
5644 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5645 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5646 }
5647 let mut p = pr(Checks::Unknown, &[], 0);
5649 p.blocking = Blocking::Conflict;
5650 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5651 p.state = PrLifecycle::Merged;
5653 assert_eq!(
5654 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5655 Step::Done { merged: true }
5656 );
5657 }
5658
5659 #[test]
5660 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5661 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5662 assert_eq!(state.number, 10);
5663 assert_eq!(state.state, PrLifecycle::Open);
5664 assert_eq!(state.checks, Checks::Green);
5665 assert!(state.failing.is_empty());
5666 assert!(
5667 state.review_comments.is_empty(),
5668 "the only comment is CodeRabbit's trigger notice: {:?}",
5669 state.review_comments
5670 );
5671 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5672 }
5673
5674 #[test]
5675 fn a_failing_check_parses_as_red_and_is_named() {
5676 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5677 assert_eq!(state.checks, Checks::Red);
5678 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5679 let mut blocking = state.clone();
5686 blocking.blocking = Blocking::Yes;
5687 match decide(&blocking, 0, 4, Duration::ZERO) {
5688 Step::Fix { reason } => {
5689 assert!(reason.contains("editorconfig"), "reason: {reason}");
5690 assert!(reason.contains("failing"), "reason: {reason}");
5691 }
5692 other => panic!("expected a fix round, got {other:?}"),
5693 }
5694 }
5695
5696 #[test]
5697 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5698 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5699 assert_eq!(state.checks, Checks::Pending);
5700 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5701 }
5702
5703 #[test]
5704 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5705 let state = parse_pr(MERGED).expect("merged fixture parses");
5706 assert_eq!(state.state, PrLifecycle::Merged);
5707 assert_eq!(
5708 decide(&state, 0, 4, Duration::ZERO),
5709 Step::Done { merged: true }
5710 );
5711 }
5712
5713 #[test]
5714 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5715 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5716 assert_eq!(state.checks, Checks::Green);
5717 let authors: Vec<&str> = state
5718 .review_comments
5719 .iter()
5720 .map(|c| c.author.as_str())
5721 .collect();
5722 assert_eq!(
5723 authors,
5724 vec!["claude"],
5725 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5726 );
5727 match decide(&state, 0, 4, Duration::ZERO) {
5728 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5729 other => panic!("expected a fix round, got {other:?}"),
5730 }
5731 }
5732
5733 #[test]
5734 fn inline_review_comments_keep_their_file_and_line() {
5735 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5736 assert_eq!(comments.len(), 1);
5737 assert_eq!(comments[0].author, "claude[bot]");
5738 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5739 assert_eq!(comments[0].line, Some(231));
5740 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5741 }
5742
5743 #[test]
5744 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5745 assert!(
5746 is_noise(CODERABBIT_TRIGGER),
5747 "CodeRabbit's trigger notice declares itself not a review"
5748 );
5749 assert!(
5750 is_noise(CLAUDE_CHECKLIST),
5751 "a progress checklist asks for nothing"
5752 );
5753 assert!(
5754 !is_noise(CLAUDE_FINDING),
5755 "a review that names a bug is input, not noise"
5756 );
5757
5758 let mut clean = pr(Checks::Green, &[], 0);
5759 clean.review_comments.push(ReviewComment {
5760 author: "coderabbitai".to_owned(),
5761 path: None,
5762 line: None,
5763 body: CODERABBIT_TRIGGER.to_owned(),
5764 });
5765 clean.review_comments.retain(|c| !is_noise(&c.body));
5766 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5767
5768 let mut found = pr(Checks::Green, &[], 0);
5769 found.review_comments.push(ReviewComment {
5770 author: "claude".to_owned(),
5771 path: None,
5772 line: None,
5773 body: CLAUDE_FINDING.to_owned(),
5774 });
5775 found.review_comments.retain(|c| !is_noise(&c.body));
5776 assert!(matches!(
5777 decide(&found, 0, 4, Duration::ZERO),
5778 Step::Fix { .. }
5779 ));
5780 }
5781
5782 #[test]
5783 fn the_policy_table_holds_for_every_combination_that_matters() {
5784 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5785 (
5786 "pending checks are waited for, even on the last round",
5787 pr(Checks::Pending, &[], 0),
5788 4,
5789 4,
5790 Duration::ZERO,
5791 Step::Wait,
5792 ),
5793 (
5794 "red checks are fixed",
5795 pr(Checks::Red, &["editorconfig"], 0),
5796 0,
5797 4,
5798 Duration::ZERO,
5799 Step::Fix {
5800 reason: "1 check(s) failing: editorconfig".to_owned(),
5801 },
5802 ),
5803 (
5804 "green with comments is fixed, not merged",
5805 pr(Checks::Green, &[], 2),
5806 1,
5807 4,
5808 Duration::ZERO,
5809 Step::Fix {
5810 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5811 .to_owned(),
5812 },
5813 ),
5814 (
5815 "green and clean merges",
5816 pr(Checks::Green, &[], 0),
5817 3,
5818 4,
5819 Duration::ZERO,
5820 Step::Merge,
5821 ),
5822 (
5823 "an unreadable rollup is waited on while the grace lasts",
5824 pr(Checks::Unknown, &[], 0),
5825 0,
5826 4,
5827 Duration::ZERO,
5828 Step::Wait,
5829 ),
5830 (
5831 "an unreadable rollup is never merged once the grace is spent",
5832 pr(Checks::Unknown, &[], 0),
5833 0,
5834 4,
5835 CHECKS_GRACE,
5836 Step::GiveUp {
5837 reason: "no check status is readable on the pull request after 3 minute(s); \
5838 refusing to merge on a guess"
5839 .to_owned(),
5840 },
5841 ),
5842 ];
5843 for (what, state, round, budget, waited, want) in cases {
5844 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5845 }
5846 }
5847
5848 #[test]
5849 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5850 let green = parse_pr(GREEN_OPEN).expect("parse");
5854 assert_eq!(green.blocking, Blocking::No);
5855 let red = parse_pr(RED_OPEN).expect("parse");
5856 assert_eq!(
5857 red.blocking,
5858 Blocking::No,
5859 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5860 );
5861 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5862 let quiet =
5864 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5865 assert_eq!(quiet.blocking, Blocking::Unsaid);
5866 }
5867
5868 #[test]
5869 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5870 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5876 nonblocking.blocking = Blocking::No;
5877 assert_eq!(
5878 decide(&nonblocking, 0, 4, Duration::ZERO),
5879 Step::Merge,
5880 "the forge says nothing is in the way, so nothing is"
5881 );
5882
5883 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5885 blocking.blocking = Blocking::Yes;
5886 assert!(matches!(
5887 decide(&blocking, 0, 4, Duration::ZERO),
5888 Step::Fix { .. }
5889 ));
5890
5891 let mut commented = pr(Checks::Red, &["coverage"], 1);
5894 commented.blocking = Blocking::No;
5895 assert!(matches!(
5896 decide(&commented, 0, 4, Duration::ZERO),
5897 Step::Fix { .. }
5898 ));
5899
5900 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5902 unsaid.blocking = Blocking::Unsaid;
5903 assert!(matches!(
5904 decide(&unsaid, 0, 4, Duration::ZERO),
5905 Step::Fix { .. }
5906 ));
5907 }
5908
5909 #[test]
5910 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5911 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5912 red.blocking = Blocking::No;
5913 assert_eq!(
5914 decide(&red, 0, 4, Duration::ZERO),
5915 Step::Merge,
5916 "announcing must not change the decision"
5917 );
5918 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5919 assert!(said.contains("yukimemi/magi"), "{said}");
5920 assert!(said.contains("#16"), "{said}");
5921 assert!(
5922 said.contains("https://github.com/yukimemi/magi/pull/16"),
5923 "{said}"
5924 );
5925 assert!(
5926 said.contains("test (windows-latest)") && said.contains("coverage"),
5927 "{said}"
5928 );
5929
5930 let green = pr(Checks::Green, &["stale"], 0);
5932 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5933 }
5934
5935 #[test]
5936 fn red_merge_notices_omit_empty_check_details() {
5937 for checks in [vec![], vec!["", " "]] {
5938 let red = pr(Checks::Red, &checks, 0);
5939 let summary = red_merge_summary("owner/repo", &red).unwrap();
5940 let notice = crate::notices::merged_red("run-1", &summary);
5941 assert_eq!(
5942 notice.message,
5943 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
5944 );
5945 assert_eq!(notice.severity, crate::notices::Severity::Warn);
5946 }
5947 let red = pr(Checks::Red, &["", " test ", " "], 0);
5948 let summary = red_merge_summary("owner/repo", &red).unwrap();
5949 assert!(summary.contains("with red checks: test ("), "{summary}");
5950 }
5951
5952 #[test]
5953 fn the_repo_label_comes_from_the_pull_request_url() {
5954 let p = Path::new("/tmp/checkout");
5955 assert_eq!(
5956 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5957 "yukimemi/magi"
5958 );
5959 assert_eq!(repo_label(p, "not a url"), "checkout");
5960 }
5961
5962 #[test]
5963 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5964 let mut conflicted = pr(Checks::Green, &[], 0);
5969 conflicted.blocking = Blocking::Conflict;
5970 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5971
5972 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5976 red.blocking = Blocking::Conflict;
5977 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5978
5979 let mut merged = pr(Checks::Red, &[], 0);
5981 merged.blocking = Blocking::Conflict;
5982 merged.state = PrLifecycle::Merged;
5983 assert_eq!(
5984 decide(&merged, 0, 4, Duration::ZERO),
5985 Step::Done { merged: true }
5986 );
5987 }
5988
5989 #[test]
5990 fn the_forge_verdict_is_read_off_merge_state_status() {
5991 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5994 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5995 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5996 }
5997 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5998 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5999 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6000 for quiet in ["", "UNKNOWN"] {
6003 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6004 assert!(Blocking::of(quiet).stops_a_merge());
6005 }
6006 }
6007
6008 #[test]
6009 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6010 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6011 let jj = "could not determine current branch: failed to run git: not on any branch";
6013
6014 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6015 .expect("the forge says merged, so it merged");
6016 assert!(landed.ok);
6017 assert!(
6018 landed.detail.contains("but the pull request is merged"),
6019 "the record must not read as a clean success: {}",
6020 landed.detail
6021 );
6022 assert!(
6023 landed.detail.contains("not on any branch"),
6024 "and it must keep what the command actually said: {}",
6025 landed.detail
6026 );
6027
6028 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6030 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6031 assert!(merged_after_all(&argv, jj, None).is_none());
6033 }
6034
6035 #[test]
6036 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6037 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6038 state.state = PrLifecycle::Closed;
6039 assert_eq!(
6040 decide(&state, 0, 4, Duration::ZERO),
6041 Step::Done { merged: false },
6042 "a human closing the pull request ends the loop, whatever CI says"
6043 );
6044 }
6045
6046 #[test]
6047 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6048 let red = decide(
6049 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6050 4,
6051 4,
6052 Duration::ZERO,
6053 );
6054 match red {
6055 Step::GiveUp { reason } => {
6056 assert!(reason.contains("editorconfig"), "reason: {reason}");
6057 assert!(reason.contains("test (macos)"), "reason: {reason}");
6058 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6059 }
6060 other => panic!("expected a give-up, got {other:?}"),
6061 }
6062
6063 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6064 match commented {
6065 Step::GiveUp { reason } => {
6066 assert!(reason.contains("unresolved"), "reason: {reason}");
6067 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6068 }
6069 other => panic!("expected a give-up, got {other:?}"),
6070 }
6071 }
6072
6073 #[test]
6074 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6075 let candidate_commit = "magi: candidate A (uncommitted work)";
6076 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6077 let argv = merge_argv(16, &subject);
6078
6079 assert!(argv.contains(&"--squash".to_owned()));
6080 assert!(argv.contains(&"--delete-branch".to_owned()));
6081 assert!(argv.contains(&"--subject".to_owned()));
6082 assert_eq!(
6083 argv.last().map(String::as_str),
6084 Some("add retries to the uploader"),
6085 "the subject must not be the candidate commit message"
6086 );
6087 assert_ne!(subject, candidate_commit);
6088 }
6089
6090 #[test]
6091 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6092 assert_eq!(
6093 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6094 "feat: a queue, an unattended loop, and a phone UI"
6095 );
6096 assert_eq!(
6097 merge_subject("", "# port the retry logic\n\ndetails"),
6098 "port the retry logic",
6099 "an empty title falls back to the task's first line, heading marks stripped"
6100 );
6101 }
6102
6103 #[test]
6104 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6105 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6106 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6107 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6108 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6109 assert_eq!(run_of(""), None);
6110 }
6111
6112 #[test]
6113 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6114 let mut out = Vec::new();
6115 push_if_outstanding(
6116 &mut out,
6117 ReviewComment {
6118 author: "yukimemi".to_owned(),
6119 path: None,
6120 line: None,
6121 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6122 },
6123 );
6124 assert!(out.is_empty());
6125 }
6126
6127 fn run_state() -> RunState {
6131 let mut state = RunState::new(
6132 std::path::PathBuf::from("/repo/magi"),
6133 "main".to_owned(),
6134 "abcdef1234".to_owned(),
6135 "add retries to the uploader".to_owned(),
6136 crate::config::Config::default(),
6137 );
6138 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6143 let nanos = std::time::SystemTime::now()
6144 .duration_since(std::time::UNIX_EPOCH)
6145 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6146 state.id = format!(
6147 "20261004-{nanos:06}-{:04x}",
6148 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6149 );
6150 state
6151 }
6152
6153 fn green_pr() -> PrState {
6154 PrState {
6155 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6156 number: 42,
6157 state: PrLifecycle::Open,
6158 checks: Checks::Green,
6159 blocking: Blocking::No,
6161 failing: Vec::new(),
6162 review_comments: vec![ReviewComment {
6163 author: "coderabbitai".to_owned(),
6164 path: Some("src/land.rs".to_owned()),
6165 line: Some(212),
6166 body: "this branch never checks the exit code".to_owned(),
6167 }],
6168 }
6169 }
6170
6171 #[test]
6172 fn land_fixer_defers_review_and_verification_without_a_cache() {
6173 let state = run_state();
6174 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6175 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6176 assert!(p.contains("Commit in this worktree"), "{p}");
6177 }
6178
6179 #[test]
6180 fn github_facing_land_text_is_english_whatever_the_language() {
6181 let mut state = run_state();
6182 state.config.graph.language = "ja".to_owned();
6183 let comment = stop_comment(&state.id, "checks are still red");
6184 assert!(crate::github_text::check("", &comment).is_empty());
6185 assert!(comment.is_ascii(), "{comment}");
6186 assert!(comment.starts_with(MARKER));
6187
6188 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6189 let ja_at = p.find("Write all prose in ja").unwrap();
6190 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6191 assert!(ja_at < rule_at, "{p}");
6192 assert!(p.contains("stays in Japanese"), "{p}");
6193
6194 state.config.graph.language = "en".to_owned();
6195 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6196 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6197 assert!(!p.contains("does not apply"), "{p}");
6198 }
6199
6200 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6201
6202 fn panel() -> String {
6203 approval_panel(
6204 &run_state(),
6205 &green_pr(),
6206 NUMSTAT,
6207 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6208 &[
6209 "land: ask before merging".to_owned(),
6210 "land: colour the diff".to_owned(),
6211 ],
6212 "feat: merge approval from the phone",
6213 )
6214 }
6215
6216 #[test]
6217 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6218 let html = panel();
6219 for needle in [
6220 "42",
6221 "main",
6222 "src/land.rs",
6223 "src/web.rs",
6224 "assets/logo.png",
6225 "feat: merge approval from the phone",
6226 "land: ask before merging",
6227 "land: colour the diff",
6228 "coderabbitai",
6229 "this branch never checks the exit code",
6230 "green",
6231 ] {
6232 assert!(html.contains(needle), "the panel must state `{needle}`");
6233 }
6234 }
6235
6236 fn winning_candidate(summary: &str) -> Candidate {
6239 Candidate {
6240 index: 0,
6241 label: 'A',
6242 agent: "opus".to_owned(),
6243 branch: "magi/x/A".to_owned(),
6244 worktree: PathBuf::from("/wt/A"),
6245 summary: summary.to_owned(),
6246 stat: String::new(),
6247 files: 1,
6248 commits: 1,
6249 empty: false,
6250 failed: None,
6251 verified_noop: None,
6252 duration_ms: 0,
6253 folded: false,
6254 }
6255 }
6256
6257 fn uncontested_tally() -> Tally {
6258 Tally {
6259 first_choice: BTreeMap::from([('A', 1)]),
6260 borda: BTreeMap::new(),
6261 winner: 'A',
6262 rankings: 1,
6263 unanimous_initial: true,
6264 deliberated: false,
6265 changed_votes: 0,
6266 unanimous_final: true,
6267 tie_break: None,
6268 judges: 1,
6269 present: 1,
6270 quorum: 1,
6271 met_quorum: true,
6272 uncontested: None,
6273 }
6274 }
6275
6276 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6277 ReviewRecord {
6278 attempts: 0,
6279 reviewer,
6280 agent: agent.to_owned(),
6281 summary: summary.to_owned(),
6282 findings: Vec::new(),
6283 vote: None,
6284 failed: None,
6285 duration_ms: 0,
6286 }
6287 }
6288
6289 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6290 let answered = reviews.len();
6291 ReviewRound {
6292 round,
6293 head: "abc1234".to_owned(),
6294 verified_head: None,
6295 verified_at: None,
6296 reviews,
6297 e2e: Vec::new(),
6298 verify_retried: false,
6299 e2e_deferred: false,
6300 e2e_defer_reason: None,
6301 fix: None,
6302 blocking: 0,
6303 answered,
6304 expected: answered,
6305 clean: true,
6306 progressed: false,
6307 vote_split: false,
6308 reconsideration: Vec::new(),
6309 verdict: None,
6310 }
6311 }
6312
6313 #[test]
6314 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6315 let en = panel();
6316 assert!(en.contains("Task"), "{en}");
6317 assert!(en.contains("add retries to the uploader"), "{en}");
6318
6319 let mut state = run_state();
6320 state.config.graph.language = "ja".to_owned();
6321 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6322 assert!(ja.contains("タスク"), "{ja}");
6323 assert!(
6324 ja.contains("add retries to the uploader"),
6325 "the task itself is not translated: {ja}"
6326 );
6327 }
6328
6329 #[test]
6330 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6331 let html = panel();
6335 assert!(!html.contains("What changed"), "{html}");
6336 assert!(!html.contains("Review verdict"), "{html}");
6337 }
6338
6339 #[test]
6340 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6341 let mut state = run_state();
6342 state.candidates = vec![winning_candidate("")];
6343 state.tally = Some(uncontested_tally());
6344 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6345 assert!(
6346 !html.contains("What changed"),
6347 "an empty summary must not render an empty box: {html}"
6348 );
6349 }
6350
6351 #[test]
6352 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6353 let mut state = run_state();
6354 state.candidates = vec![winning_candidate(
6355 "Added a retry loop around the uploader PUT call.",
6356 )];
6357 state.tally = Some(uncontested_tally());
6358 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6359 assert!(en.contains("What changed"), "{en}");
6360 assert!(
6361 en.contains("Added a retry loop around the uploader PUT call."),
6362 "{en}"
6363 );
6364
6365 state.config.graph.language = "ja".to_owned();
6366 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6367 assert!(ja.contains("変更内容"), "{ja}");
6368 assert!(
6369 ja.contains("Added a retry loop around the uploader PUT call."),
6370 "{ja}"
6371 );
6372 }
6373
6374 #[test]
6375 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6376 let mut state = run_state();
6377 state.reviews = vec![
6378 review_round(
6379 1,
6380 vec![review_record(1, "alpha", "found a race, sent back")],
6381 ),
6382 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6383 ];
6384 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6385 assert!(en.contains("Review verdict"), "{en}");
6386 assert!(en.contains("race is fixed, clean"), "{en}");
6387 assert!(
6388 !en.contains("found a race, sent back"),
6389 "only the round that actually cleared the merge should show: {en}"
6390 );
6391
6392 state.config.graph.language = "ja".to_owned();
6393 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6394 assert!(ja.contains("レビューの結論"), "{ja}");
6395 assert!(ja.contains("レビュアー"), "{ja}");
6396 assert!(ja.contains("race is fixed, clean"), "{ja}");
6397 }
6398
6399 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6405 ReviewRecord {
6406 attempts: 0,
6407 reviewer,
6408 agent: agent.to_owned(),
6409 summary: String::new(),
6410 findings: Vec::new(),
6411 vote: None,
6412 failed: Some(reason.to_owned()),
6413 duration_ms: 0,
6414 }
6415 }
6416
6417 #[test]
6418 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6419 let mut state = run_state();
6420 state.reviews = vec![review_round(
6421 1,
6422 vec![
6423 review_record(1, "alpha", "clean, nothing to add"),
6424 unanswered_review_record(2, "beta", "timed out"),
6425 ],
6426 )];
6427 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6428 assert!(en.contains("clean, nothing to add"), "{en}");
6429 assert!(
6430 en.contains("produced no answer: timed out"),
6431 "a seat that never answered must say so, not render a blank box: {en}"
6432 );
6433 assert!(
6434 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6435 "no reviewer box may be left empty: {en}"
6436 );
6437
6438 state.config.graph.language = "ja".to_owned();
6439 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6440 assert!(ja.contains("回答なし: timed out"), "{ja}");
6441 }
6442
6443 #[test]
6444 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6445 let html = panel();
6446 assert!(!html.contains("<script"), "no script survives the csp");
6447 assert!(!html.contains("<form"), "form-action is 'none'");
6448 let pr = green_pr();
6449 assert_eq!(
6450 html.matches("http").count(),
6451 html.matches(pr.url.as_str()).count(),
6452 "the only http url in the panel is the pull request's own link"
6453 );
6454 assert!(
6455 html.contains("target=\"_blank\""),
6456 "the PR link must open in a new tab so it escapes the iframe"
6457 );
6458 assert!(
6459 html.contains("rel=\"noopener noreferrer\""),
6460 "external links opening in new tabs must carry noopener noreferrer"
6461 );
6462 }
6463
6464 #[test]
6465 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6466 let html = panel();
6467 assert!(
6468 html.contains(">+</span>"),
6469 "an added line carries a `+` in the gutter, not only a background"
6470 );
6471 assert!(
6472 html.contains(">-</span>"),
6473 "a removed line carries a `-` in the gutter, not only a background"
6474 );
6475 assert!(
6476 html.contains(">new line</span>"),
6477 "the marker is moved to the gutter, so the body is printed once without it"
6478 );
6479 }
6480
6481 #[test]
6482 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6483 let total = DIFF_MAX_LINES + 100;
6484 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6485 let html = approval_panel(
6486 &run_state(),
6487 &green_pr(),
6488 NUMSTAT,
6489 &diff,
6490 &[],
6491 "feat: something long",
6492 );
6493 assert!(
6494 html.contains(&format!("100 of {total} diff lines omitted")),
6495 "the note must say exactly how much was cut"
6496 );
6497 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6498 assert!(
6499 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6500 "nothing past the threshold is rendered"
6501 );
6502 assert!(
6503 html.contains("/repo/magi"),
6504 "the note says where the rest is"
6505 );
6506 }
6507
6508 #[test]
6509 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6510 let html = approval_panel(
6511 &run_state(),
6512 &green_pr(),
6513 "1\t2\tsrc/<b>&\"x\"'.rs",
6514 "",
6515 &[],
6516 "subject",
6517 );
6518 assert!(html.contains("src/<b>&"x"'.rs"));
6519 assert!(
6520 !html.contains("<b>"),
6521 "an agent-influenced path must never become markup"
6522 );
6523 }
6524
6525 #[tokio::test]
6526 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6527 let a = std::path::PathBuf::from("/repo/a");
6528 let b = std::path::PathBuf::from("/repo/b");
6529
6530 let held = repo_merge_lock(&a).lock_owned().await;
6531
6532 assert!(
6535 repo_merge_lock(&a).try_lock().is_err(),
6536 "a second merge into the same repository must not proceed concurrently"
6537 );
6538
6539 assert!(
6543 repo_merge_lock(&b).try_lock().is_ok(),
6544 "a different repository's merge lock must be independent"
6545 );
6546
6547 drop(held);
6548 assert!(
6549 repo_merge_lock(&a).try_lock().is_ok(),
6550 "the lock is released once the holder is done"
6551 );
6552 }
6553
6554 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6555 let mut state = landing_state();
6556 let mut rec = review_record(1, "alpha", "precise rejection reason");
6557 rec.vote = Some(if reject {
6558 crate::verdict::ReviewVote::Reject
6559 } else {
6560 crate::verdict::ReviewVote::Approve
6561 });
6562 if major {
6563 rec.findings = contested().findings;
6564 }
6565 let mut round = review_round(1, vec![rec]);
6566 round.clean = false;
6567 round.blocking = usize::from(major);
6568 state.reviews.push(round);
6569 state.config.graph.review_rounds = 1;
6570 state
6571 }
6572
6573 #[test]
6574 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6575 for (major, reject) in [(true, false), (false, true), (true, true)] {
6576 let state = repair_state(major, reject);
6577 assert!(repair_available(&state));
6578 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
6579 }
6580 assert!(!repair_available(&repair_state(false, false)));
6581 let mut clean = repair_state(true, false);
6582 clean.reviews[0].clean = true;
6583 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
6584 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6585 }
6586
6587 #[tokio::test]
6588 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6589 let mut state = repair_state(true, false);
6590 let pr = green_pr();
6591 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6592 .await
6593 .unwrap();
6594 let store = ask::Questions::open();
6595 let id = state.land_approval.as_ref().unwrap().question.clone();
6596 store
6598 .update(&id, |q| {
6599 q.choices = vec![APPROVE.into(), HOLD.into()];
6600 Ok(())
6601 })
6602 .unwrap();
6603 let upgraded = store.get(&id).unwrap();
6604 assert_eq!(upgraded.choices, approval_choices(&state));
6605 assert!(!upgraded.actions.contains_key(HOLD));
6606 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6607 assert!(
6608 store
6609 .list()
6610 .iter()
6611 .any(|q| q.id == id && q.choices == upgraded.choices)
6612 );
6613 assert!(
6614 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6615 );
6616 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6617 store
6618 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6619 .unwrap();
6620 store
6623 .update(&id, |q| {
6624 q.actions.clear();
6625 Ok(())
6626 })
6627 .unwrap();
6628 assert_eq!(
6629 store.get(&id).unwrap().chosen_action(),
6630 Some(&ask::ChoiceAction::Resume {
6631 run: state.id.clone()
6632 })
6633 );
6634 let binding = state.land_approval.clone();
6635 assert_eq!(
6636 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6637 .await
6638 .unwrap(),
6639 ApprovalGate::Repair
6640 );
6641 assert!(state.land_approval.is_none());
6642 assert_eq!(state.merge_repairs.len(), 1);
6643 let request = &state.merge_repairs[0];
6644 assert_eq!(request.question, id);
6645 assert_eq!(request.head, "abc");
6646 assert_eq!(request.pr, pr.url);
6647 assert_eq!(
6648 request.reviews[0].reviews[0].summary,
6649 "precise rejection reason"
6650 );
6651 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6652 assert_eq!(
6653 request.reviews[0].reviews[0].findings[0].detail,
6654 state.reviews[0].reviews[0].findings[0].detail
6655 );
6656 state.land_approval = binding;
6658 assert_eq!(
6659 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6660 .await
6661 .unwrap(),
6662 ApprovalGate::Repair
6663 );
6664 assert_eq!(state.merge_repairs.len(), 1);
6665 }
6666
6667 #[tokio::test]
6668 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6669 for choice in [REPAIR, HOLD] {
6670 let mut state = repair_state(false, true);
6671 let observed = seen("abc", Checks::Green, "CLEAN", false);
6672 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6673 .await
6674 .unwrap();
6675 let id = state.land_approval.as_ref().unwrap().question.clone();
6676 ask::Questions::open()
6677 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6678 .unwrap();
6679 let forge = Scripted::new(vec![observed], vec![(true, "")]);
6680 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6681 .await
6682 .unwrap();
6683 assert!(
6684 forge
6685 .argvs()
6686 .iter()
6687 .all(|a| a.iter().any(|s| s == "--disable-auto"))
6688 );
6689 assert!(!forge.calls().contains(&"fix"));
6690 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6691 assert_ne!(state.status, RunStatus::Merged);
6692 }
6693 }
6694
6695 #[test]
6696 fn only_the_merge_choice_merges_and_silence_holds() {
6697 let table = [
6698 (None, Approval::Hold),
6699 (Some("merge"), Approval::Merge),
6700 (Some(" merge\n"), Approval::Merge),
6701 (Some("hold"), Approval::Hold),
6702 (Some(""), Approval::Hold),
6703 (Some("yes"), Approval::Hold),
6704 ];
6705 for (answer, want) in table {
6706 assert_eq!(
6707 approval(answer),
6708 want,
6709 "answer {answer:?} must resolve to {want:?}"
6710 );
6711 }
6712 }
6713
6714 #[tokio::test]
6715 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6716 let mut state = landing_state();
6717 state.config.graph.land_approval = true;
6718 let pr = green_pr();
6719
6720 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6721 .await
6722 .unwrap();
6723 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6724 assert!(
6725 !state.parked,
6726 "approval_gate itself never sets `parked`; only its caller does"
6727 );
6728
6729 let store = ask::Questions::open();
6730 let filed: Vec<_> = store
6731 .list()
6732 .into_iter()
6733 .filter(|q| q.run == state.id)
6734 .collect();
6735 assert_eq!(filed.len(), 1, "exactly one question is filed");
6736 assert_eq!(filed[0].node, APPROVAL_NODE);
6737 assert_eq!(filed[0].choices, approval_choices(&state));
6738 assert!(filed[0].status.open());
6739
6740 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6744 .await
6745 .unwrap();
6746 assert_eq!(again, ApprovalGate::Pending);
6747 let still_one = store
6748 .list()
6749 .into_iter()
6750 .filter(|q| q.run == state.id)
6751 .count();
6752 assert_eq!(
6753 still_one, 1,
6754 "asking twice must not double-file the question"
6755 );
6756 }
6757
6758 #[tokio::test]
6759 async fn approving_the_existing_question_is_read_back_as_approved() {
6760 crate::run::pin_test_home();
6761 let mut state = run_state();
6762 state.config.graph.land_approval = true;
6763 let pr = green_pr();
6764 assert_eq!(
6765 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6766 .await
6767 .unwrap(),
6768 ApprovalGate::Pending
6769 );
6770
6771 let store = ask::Questions::open();
6772 let mut q = store
6773 .list()
6774 .into_iter()
6775 .find(|q| q.run == state.id)
6776 .expect("filed above");
6777 let talks = crate::talk::Talks::open();
6778 let cfg = crate::config::Config {
6779 agents: vec![crate::config::AgentSpec {
6780 id: "mock".into(),
6781 kind: crate::config::AgentKind::Command,
6782 model: None,
6783 command: vec!["true".into()],
6784 extra_args: Vec::new(),
6785 env: Default::default(),
6786 prompt_delivery: None,
6787 }],
6788 ..crate::config::Config::default()
6789 };
6790 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6791 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6792 crate::talk::drain(&mut talk, &talks).unwrap();
6793 assert_eq!(
6794 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6795 .await
6796 .unwrap(),
6797 ApprovalGate::Pending,
6798 "consultation cannot approve or hold the merge"
6799 );
6800 q = store.get(&q.id).unwrap();
6801 assert!(q.answer.is_none());
6802 talk.turns.push(crate::talk::Turn {
6803 sender: None,
6804 breaks: Some(Vec::new()),
6805 messages: Vec::new(),
6806 posted_by: None,
6807 who: crate::talk::Who::Operator,
6808 body: "merge abc".into(),
6809 at: Timestamp::now(),
6810 attachments: Vec::new(),
6811 usage: None,
6812 });
6813 talks.put(&mut talk).unwrap();
6814 crate::consult::validate_answer(
6815 &q,
6816 &talks,
6817 &talk.id,
6818 crate::queue::CHAT_NODE,
6819 APPROVE,
6820 Some("merge abc"),
6821 )
6822 .unwrap();
6823 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6824 store.put(&mut q).unwrap();
6825
6826 assert_eq!(
6827 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6828 .await
6829 .unwrap(),
6830 ApprovalGate::Approved
6831 );
6832 }
6833
6834 #[tokio::test]
6835 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
6836 crate::run::pin_test_home();
6837 let store = ask::Questions::open();
6838 for via_list in [false, true] {
6839 let mut state = repair_state(false, true);
6840 let pr = green_pr();
6841 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6842 .await
6843 .unwrap();
6844 let id = state.land_approval.as_ref().unwrap().question.clone();
6845 store
6846 .update(&id, |q| {
6847 q.actions.insert(
6848 HOLD.to_owned(),
6849 ask::ChoiceAction::Resume {
6850 run: state.id.clone(),
6851 },
6852 );
6853 Ok(())
6854 })
6855 .unwrap();
6856 let q = if via_list {
6857 store.list().into_iter().find(|q| q.id == id).unwrap()
6858 } else {
6859 store.get(&id).unwrap()
6860 };
6861 assert!(!q.actions.contains_key(HOLD));
6862 store
6863 .update(&id, |q| {
6864 assert!(!q.actions.contains_key(HOLD));
6866 assert!(!upgrade_repair_choice(q, &state));
6867 q.answer(ask::Answer::Choice(HOLD.to_owned()))
6868 })
6869 .unwrap();
6870 assert!(store.get(&id).unwrap().chosen_action().is_none());
6871 assert_eq!(
6872 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6873 .await
6874 .unwrap(),
6875 ApprovalGate::Held
6876 );
6877 }
6878 }
6879
6880 #[tokio::test]
6881 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6882 crate::run::pin_test_home();
6883 let store = ask::Questions::open();
6884
6885 let mut held_state = run_state();
6886 held_state.config.graph.land_approval = true;
6887 let pr = green_pr();
6888 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6889 .await
6890 .unwrap();
6891 let mut q = store
6892 .list()
6893 .into_iter()
6894 .find(|q| q.run == held_state.id)
6895 .expect("filed above");
6896 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6897 store.put(&mut q).unwrap();
6898 assert_eq!(
6899 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6900 .await
6901 .unwrap(),
6902 ApprovalGate::Held
6903 );
6904
6905 let mut abandoned_state = run_state();
6906 abandoned_state.config.graph.land_approval = true;
6907 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6908 .await
6909 .unwrap();
6910 let mut q = store
6911 .list()
6912 .into_iter()
6913 .find(|q| q.run == abandoned_state.id)
6914 .expect("filed above");
6915 q.abandon("no answer within the timeout");
6916 store.put(&mut q).unwrap();
6917 assert_eq!(
6918 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6919 .await
6920 .unwrap(),
6921 ApprovalGate::Held,
6922 "silence must never merge"
6923 );
6924 }
6925
6926 fn contested() -> ContestedHandoff {
6927 let finding = |id: &str, n: u32| crate::verdict::Finding {
6928 id: id.to_owned(),
6929 severity: crate::verdict::Severity::Major,
6930 file: Some("src/a.rs".to_owned()),
6931 line: Some(n),
6932 title: format!("problem {id}"),
6933 detail: String::new(),
6934 };
6935 ContestedHandoff {
6936 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6937 rejecters: vec![(1, "alpha".to_owned())],
6938 }
6939 }
6940
6941 #[test]
6942 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6943 let mut state = run_state();
6944 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6945 state.contested_handoff = Some(contested());
6946 assert!(contested_to_ask(&state).is_some());
6947 state.config.graph.hold_contested_merge = false;
6948 assert!(
6949 contested_to_ask(&state).is_none(),
6950 "the switch restores today"
6951 );
6952 }
6953
6954 #[test]
6955 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6956 let q = ask::Question::new(
6957 "run-1".to_owned(),
6958 APPROVAL_NODE.to_owned(),
6959 "land".to_owned(),
6960 "Merge?".to_owned(),
6961 String::new(),
6962 vec![APPROVE.to_owned(), HOLD.to_owned()],
6963 );
6964 let none = deputy_brief(&q, None);
6965 assert!(none.contains("could not be read"), "{none}");
6966 assert!(none.contains("Silence is a hold"), "{none}");
6967
6968 let mut state = run_state();
6969 state.pr = Some(crate::run::PrRecord {
6970 url: "https://example.test/pull/7".to_owned(),
6971 number: 7,
6972 state: "open".to_owned(),
6973 checks: "green".to_owned(),
6974 round: 0,
6975 rounds: 3,
6976 red_at_merge: Vec::new(),
6977 });
6978 state.contested_handoff = Some(contested());
6979 let b = deputy_brief(&q, Some(&state));
6980 assert!(b.contains("https://example.test/pull/7"), "{b}");
6981 assert!(b.contains("[R3-1-1] major (src/a.rs:1)"), "{b}");
6982 assert!(b.contains("#1"), "the rejecting seat: {b}");
6983 state.contested_handoff = None;
6984 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6985 }
6986
6987 #[test]
6988 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6989 for lang in ["en", "ja"] {
6990 let mut cfg = crate::config::Config::default();
6991 cfg.graph.language = lang.to_owned();
6992 let w = words(&cfg.graph.language);
6993 let text = w.approval_detail(
6994 "https://github.com/yukimemi/magi/pull/42",
6995 "main",
6996 "feat: x",
6997 Some(&contested()),
6998 );
6999 assert!(text.contains("pull/42"), "{text}");
7000 assert!(
7001 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
7002 "{text}"
7003 );
7004 assert!(text.contains("R3-1-5"), "{text}");
7005 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
7006 assert!(text.contains("2"), "the rest are counted: {text}");
7007 assert!(text.contains("#1 (alpha)"), "{text}");
7008 }
7009 let plain = words("en").approval_detail("u", "main", "s", None);
7010 assert!(!plain.contains("reject"), "{plain}");
7011 }
7012
7013 #[tokio::test]
7014 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
7015 crate::run::pin_test_home();
7016 let mut state = run_state();
7017 state.config.graph.land_approval = false;
7018 state.contested_handoff = Some(contested());
7019 let pr = green_pr();
7020 let c = contested_to_ask(&state);
7021 assert_eq!(
7022 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7023 .await
7024 .unwrap(),
7025 ApprovalGate::Pending,
7026 "silence is a hold"
7027 );
7028 let store = ask::Questions::open();
7029 let filed: Vec<_> = store
7030 .list()
7031 .into_iter()
7032 .filter(|q| q.run == state.id)
7033 .collect();
7034 assert_eq!(filed.len(), 1);
7035 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7036
7037 assert_eq!(
7038 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7039 .await
7040 .unwrap(),
7041 ApprovalGate::Pending
7042 );
7043 let mut q = store
7044 .list()
7045 .into_iter()
7046 .find(|q| q.run == state.id)
7047 .unwrap();
7048 assert_eq!(q.id, filed[0].id, "the same question after a resume");
7049 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7050 store.put(&mut q).unwrap();
7051 assert_eq!(
7052 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7053 .await
7054 .unwrap(),
7055 ApprovalGate::Approved
7056 );
7057 }
7058
7059 #[test]
7060 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7061 let rows = parse_numstat(NUMSTAT);
7062 assert_eq!(
7063 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7064 ["src/web.rs", "src/land.rs", "assets/logo.png"]
7065 );
7066 assert_eq!(rows[2].added, None, "a binary file has no line counts");
7067 }
7068 #[test]
7069 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7070 let mut state = run_state();
7074 state.config.graph.language = "ja".to_owned();
7075 let pr = green_pr();
7076 let commits = ["c1".to_owned()];
7077
7078 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7079 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7080 assert!(ja.contains("squash されるコミット"), "{ja}");
7081 assert!(ja.contains("レビューコメント"), "{ja}");
7082 assert!(ja.contains("差分"), "{ja}");
7083 assert!(
7084 !ja.contains("Commits being squashed"),
7085 "no English left over"
7086 );
7087
7088 let w = words("ja");
7089 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7090 assert!(
7091 w.approval_detail("http://x/1", "main", "feat: x", None)
7092 .contains("パネル")
7093 );
7094
7095 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7097 assert!(ja.contains("feat: x"), "nor is the merge subject");
7098
7099 state.config.graph.language = "en".to_owned();
7102 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7103 assert!(en.contains("Commits being squashed"), "{en}");
7104 assert_eq!(words("Klingon").html_lang, "en");
7105 }
7106
7107 #[test]
7111 fn pick_open_pr_classifies_by_count_and_base() {
7112 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7113 assert_eq!(
7114 pick_open_pr(one, "main").unwrap(),
7115 OpenPr::One {
7116 url: "https://x/pull/58".into(),
7117 title: "t".into()
7118 }
7119 );
7120 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7121 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7122 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7123 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7124 assert_eq!(
7125 pick_open_pr(two, "main").unwrap(),
7126 OpenPr::Many(vec!["u1".into(), "u2".into()])
7127 );
7128 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7130 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7131 assert!(pick_open_pr("not json", "main").is_err());
7132 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7134 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7135 }
7136
7137 #[test]
7138 fn pick_merged_pr_picks_the_unique_match() {
7139 let json = r#"[
7140 {"url": "https://github.com/o/r/pull/42", "number": 42,
7141 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7142 ]"#;
7143 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7144 let found = pick_merged_pr(json, "main", created_at)
7145 .expect("valid json")
7146 .expect("one unambiguous match");
7147 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7148 assert_eq!(found.number, 42);
7149 }
7150
7151 #[test]
7155 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7156 let json = r#"[
7157 {"url": "https://github.com/o/r/pull/42", "number": 42,
7158 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7159 {"url": "https://github.com/o/r/pull/43", "number": 43,
7160 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7161 ]"#;
7162 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7163 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7164 }
7165
7166 #[test]
7170 fn pick_merged_pr_ignores_a_different_base_branch() {
7171 let json = r#"[
7172 {"url": "https://github.com/o/r/pull/42", "number": 42,
7173 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7174 ]"#;
7175 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7176 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7177 }
7178
7179 #[test]
7182 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7183 let json = r#"[
7184 {"url": "https://github.com/o/r/pull/42", "number": 42,
7185 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7186 ]"#;
7187 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7188 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7189 }
7190
7191 #[test]
7192 fn slug_of_pr_url_reads_host_owner_and_repo() {
7193 assert_eq!(
7194 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7195 Some("github.com/yukimemi/shun")
7196 );
7197 }
7198
7199 #[test]
7200 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7201 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7202 assert_eq!(slug_of_pr_url("not a url at all"), None);
7203 assert_eq!(slug_of_pr_url("https://github.com"), None);
7204 }
7205
7206 #[test]
7207 fn slug_of_repo_url_reads_host_owner_and_repo() {
7208 assert_eq!(
7209 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7210 Some("github.com/yukimemi/magi")
7211 );
7212 assert_eq!(slug_of_repo_url("https://github.com"), None);
7213 }
7214
7215 #[test]
7216 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7217 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7218 .expect("same repo, different case");
7219 }
7220
7221 #[test]
7226 fn ensure_same_repo_refuses_a_different_repo() {
7227 let err =
7228 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7229 let msg = format!("{err:#}");
7230 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7231 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7232 }
7233
7234 #[test]
7239 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7240 let err = ensure_same_repo(
7241 "github.com/yukimemi/magi",
7242 "github.example.com/yukimemi/magi",
7243 )
7244 .unwrap_err();
7245 let msg = format!("{err:#}");
7246 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7247 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7248 }
7249
7250 #[tokio::test]
7255 async fn find_external_merge_returns_none_without_a_winner() {
7256 let state = RunState::new(
7257 PathBuf::from("/no/such/repo"),
7258 "main".to_owned(),
7259 "0000000000000000000000000000000000000000".to_owned(),
7260 "irrelevant".to_owned(),
7261 crate::config::Config::default(),
7262 );
7263 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7264 }
7265
7266 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7267 let mut run = RunState::new(
7268 PathBuf::from(repo),
7269 "main".to_owned(),
7270 "abcdef1234".to_owned(),
7271 "x".to_owned(),
7272 crate::config::Config::default(),
7273 );
7274 run.id = id.to_owned();
7275 run.status = status;
7276 run.driver_exited = true;
7277 run.pr = Some(crate::run::PrRecord {
7278 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7279 url: url.to_owned(),
7280 state: state.to_owned(),
7281 checks: "red".to_owned(),
7282 round: 0,
7283 rounds: 2,
7284 red_at_merge: Vec::new(),
7285 });
7286 run.save_under(home).unwrap();
7287 }
7288
7289 fn recorded(home: &Path, id: &str) -> String {
7290 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7291 serde_json::from_str::<RunState>(&body)
7292 .unwrap()
7293 .pr
7294 .unwrap()
7295 .state
7296 }
7297
7298 const PR: &str = "https://github.com/o/r/pull/7";
7299
7300 #[test]
7301 fn write_through_updates_predecessors_and_siblings_only() {
7302 let tmp = tempfile::tempdir().unwrap();
7303 let h = tmp.path();
7304 pr_run(
7305 h,
7306 "20261004-100000-aaaa",
7307 "/repo/r",
7308 RunStatus::Superseded,
7309 PR,
7310 "open",
7311 );
7312 pr_run(
7313 h,
7314 "20261004-100100-bbbb",
7315 "/repo/r",
7316 RunStatus::Blocked,
7317 PR,
7318 "open",
7319 );
7320 pr_run(
7322 h,
7323 "20261004-100200-cccc",
7324 "/repo/r",
7325 RunStatus::Landing,
7326 PR,
7327 "open",
7328 );
7329 pr_run(
7331 h,
7332 "20261004-100300-dddd",
7333 "/repo/other",
7334 RunStatus::Blocked,
7335 "https://github.com/o/other/pull/7",
7336 "open",
7337 );
7338 pr_run(
7340 h,
7341 "20261004-100400-eeee",
7342 "/repo/r",
7343 RunStatus::Blocked,
7344 "https://github.com/o/r/pull/8",
7345 "open",
7346 );
7347 pr_run(
7348 h,
7349 "20261004-100500-ffff",
7350 "/repo/r",
7351 RunStatus::Merged,
7352 PR,
7353 "open",
7354 );
7355 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7356
7357 assert_eq!(
7358 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7359 2
7360 );
7361 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7362 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7363 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7364 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7365 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7366 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7368 assert_eq!(
7370 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7371 0
7372 );
7373 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7374 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7375 }
7376
7377 #[test]
7378 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
7379 let home = tempfile::tempdir().unwrap();
7380 let live_id = "20261004-100000-aaaa";
7381 let unknown_id = "20261004-100100-bbbb";
7382 for id in [live_id, unknown_id] {
7383 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
7384 let mut s = RunState::load_under(id, home.path()).unwrap();
7385 s.driver_exited = false;
7386 if id == live_id {
7387 s.driver_pid = Some(std::process::id());
7388 s.driver_started_at = crate::proc::process_started_at(std::process::id());
7389 }
7390 s.save_under(home.path()).unwrap();
7391 }
7392 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
7393 assert_eq!(apply_pr_states(home.path(), &known), 0);
7394 for id in [live_id, unknown_id] {
7395 assert_eq!(recorded(home.path(), id), "open");
7396 }
7397 }
7398
7399 #[test]
7400 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7401 let tmp = tempfile::tempdir().unwrap();
7402 let h = tmp.path();
7403 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7404 pr_run(
7405 h,
7406 "20261004-100000-aaaa",
7407 "/repo/r",
7408 RunStatus::Superseded,
7409 &url(1),
7410 "open",
7411 );
7412 pr_run(
7413 h,
7414 "20261004-100100-bbbb",
7415 "/repo/r",
7416 RunStatus::Blocked,
7417 &url(2),
7418 "open",
7419 );
7420 pr_run(
7421 h,
7422 "20261004-100200-cccc",
7423 "/repo/r",
7424 RunStatus::Ready,
7425 &url(3),
7426 "open",
7427 );
7428 pr_run(
7429 h,
7430 "20261004-100300-dddd",
7431 "/repo/r",
7432 RunStatus::Ready,
7433 &url(4),
7434 "open",
7435 );
7436 pr_run(
7437 h,
7438 "20261004-100400-eeee",
7439 "/repo/r",
7440 RunStatus::Implementing,
7441 &url(1),
7442 "open",
7443 );
7444 assert_eq!(stale_open_prs(h).len(), 4);
7445
7446 let mut known = BTreeMap::new();
7447 known.insert(url(1), PrLifecycle::Merged);
7448 known.insert(url(2), PrLifecycle::Closed);
7449 known.insert(url(3), PrLifecycle::Open);
7450 assert_eq!(apply_pr_states(h, &known), 2);
7452 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7453 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
7454 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7455 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7456 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7457 assert_eq!(apply_pr_states(h, &known), 0);
7458 }
7459
7460 use std::collections::VecDeque;
7463 use std::sync::Mutex;
7464
7465 struct Scripted {
7468 views: Mutex<VecDeque<Seen>>,
7469 merges: Mutex<VecDeque<(bool, String)>>,
7470 fix: Mutex<Option<Fixed>>,
7471 log: Mutex<Vec<&'static str>>,
7472 argvs: Mutex<Vec<Vec<String>>>,
7473 required: Mutex<Option<BTreeSet<String>>>,
7474 merged: Mutex<bool>,
7477 queued: Mutex<bool>,
7478 unreadable_after_merge: Mutex<bool>,
7480 reruns: Mutex<Vec<String>>,
7482 rerun_fails: Mutex<bool>,
7483 clock: Mutex<i64>,
7485 }
7486
7487 impl Scripted {
7488 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
7489 Self {
7490 views: Mutex::new(views.into()),
7491 merges: Mutex::new(
7492 merges
7493 .into_iter()
7494 .map(|(ok, m)| (ok, m.to_owned()))
7495 .collect(),
7496 ),
7497 fix: Mutex::new(None),
7498 log: Mutex::new(Vec::new()),
7499 argvs: Mutex::new(Vec::new()),
7500 required: Mutex::new(None),
7501 merged: Mutex::new(false),
7502 queued: Mutex::new(false),
7503 unreadable_after_merge: Mutex::new(false),
7504 reruns: Mutex::new(Vec::new()),
7505 rerun_fails: Mutex::new(false),
7506 clock: Mutex::new(1_000),
7507 }
7508 }
7509 fn argvs(&self) -> Vec<Vec<String>> {
7510 self.argvs.lock().unwrap().clone()
7511 }
7512 fn calls(&self) -> Vec<&'static str> {
7513 self.log.lock().unwrap().clone()
7514 }
7515 }
7516
7517 impl Forge for Scripted {
7518 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7519 self.log.lock().unwrap().push("view");
7520 if *self.unreadable_after_merge.lock().unwrap()
7521 && !self.argvs.lock().unwrap().is_empty()
7522 {
7523 anyhow::bail!("forge unreachable");
7524 }
7525 let mut v = self.views.lock().unwrap();
7526 let mut seen = if v.len() > 1 {
7527 v.pop_front().unwrap()
7528 } else {
7529 v[0].clone()
7530 };
7531 if *self.merged.lock().unwrap() {
7532 seen.pr.state = PrLifecycle::Merged;
7533 }
7534 Ok(seen)
7535 }
7536 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7537 self.log.lock().unwrap().push("merge");
7538 self.argvs.lock().unwrap().push(argv.to_vec());
7539 let out = self
7540 .merges
7541 .lock()
7542 .unwrap()
7543 .pop_front()
7544 .expect("unscripted merge");
7545 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7546 {
7547 *self.merged.lock().unwrap() = true;
7548 }
7549 Ok(out)
7550 }
7551 async fn poll(&self) {
7552 self.log.lock().unwrap().push("poll");
7553 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7554 }
7555 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7556 self.log.lock().unwrap().push("rerun");
7557 self.reruns.lock().unwrap().push(run.to_owned());
7558 if *self.rerun_fails.lock().unwrap() {
7559 anyhow::bail!("gh run rerun: no");
7560 }
7561 Ok(())
7562 }
7563 fn now(&self) -> i64 {
7564 *self.clock.lock().unwrap()
7565 }
7566 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7567 self.required.lock().unwrap().clone()
7568 }
7569 async fn fix(
7570 &self,
7571 _state: &mut RunState,
7572 _pr: &PrState,
7573 _round: usize,
7574 _budget: usize,
7575 _reason: &str,
7576 _logs: &str,
7577 ) -> Result<Fixed> {
7578 self.log.lock().unwrap().push("fix");
7579 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7580 }
7581 }
7582
7583 const REFUSED: &str =
7584 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7585
7586 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7587 let mut pr = green_pr();
7588 pr.checks = checks;
7589 pr.blocking = Blocking::of(merge_state);
7590 if !comments {
7591 pr.review_comments.clear();
7592 }
7593 Seen {
7594 pr,
7595 title: "feat: x".to_owned(),
7596 failing_urls: Vec::new(),
7597 head: head.to_owned(),
7598 rollup_head: head.to_owned(),
7599 merge_state: merge_state.to_owned(),
7600 contexts: Vec::new(),
7601 base: "main".to_owned(),
7602 }
7603 }
7604
7605 fn landing_state() -> RunState {
7606 crate::run::pin_test_home();
7607 let mut state = run_state();
7608 state.config.graph.land_approval = false;
7609 state
7610 }
7611
7612 #[test]
7613 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7614 assert!(!awaiting_new_head(None, "aaa"));
7615 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7616 assert!(awaiting_new_head(Some("abc123"), "def456"));
7617 assert!(awaiting_new_head(Some("abc123"), ""));
7618 }
7619
7620 #[test]
7621 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7622 let open = |c, m: &str| seen("a", c, m, false);
7623 let table = [
7624 (None, false, Refused::Pending),
7625 (
7626 Some(open(Checks::Pending, "BLOCKED")),
7627 false,
7628 Refused::Pending,
7629 ),
7630 (
7631 Some(open(Checks::Unknown, "BLOCKED")),
7632 false,
7633 Refused::Pending,
7634 ),
7635 (
7636 Some(open(Checks::Green, "UNKNOWN")),
7637 false,
7638 Refused::Pending,
7639 ),
7640 (Some(open(Checks::Green, "")), false, Refused::Pending),
7641 (
7642 Some(open(Checks::Green, "BLOCKED")),
7643 false,
7644 Refused::Recheck,
7645 ),
7646 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7647 ];
7648 for (after, rechecked, want) in table {
7649 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7650 }
7651 let mut closed = open(Checks::Green, "CLEAN");
7652 closed.pr.state = PrLifecycle::Closed;
7653 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7654 }
7655
7656 #[tokio::test]
7657 async fn a_normal_landing_merges_on_the_first_look() {
7658 let mut state = landing_state();
7659 let forge = Scripted::new(
7660 vec![seen("a", Checks::Green, "CLEAN", false)],
7661 vec![(true, "")],
7662 );
7663 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7664 .await
7665 .unwrap();
7666 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7668 assert_eq!(state.status, RunStatus::Merged);
7669 }
7670
7671 #[tokio::test]
7672 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7673 let mut state = landing_state();
7674 let forge = Scripted::new(
7675 vec![seen("a", Checks::Green, "CLEAN", false)],
7676 std::iter::repeat_n((true, ""), 100).collect(),
7677 );
7678 *forge.queued.lock().unwrap() = true;
7679 let task = async {
7680 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7681 .await
7682 .unwrap();
7683 };
7684 task.await;
7687 assert_ne!(state.status, RunStatus::Merged);
7688 }
7689
7690 #[tokio::test]
7691 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7692 let mut state = landing_state();
7693 let forge = Scripted::new(
7694 vec![seen("a", Checks::Green, "CLEAN", false)],
7695 std::iter::repeat_n((true, ""), 100).collect(),
7696 );
7697 *forge.unreadable_after_merge.lock().unwrap() = true;
7698 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7699 .await
7700 .ok();
7701 assert_ne!(state.status, RunStatus::Merged);
7702 }
7703
7704 #[tokio::test]
7705 async fn a_task_public_head_is_awaited_on_land_entry() {
7706 let mut state = landing_state();
7707 state.pr_head = Some("magi/task-abcd-1234".into());
7708 state.pr_pushed_head = Some("new".into());
7709 let forge = Scripted::new(
7710 vec![
7711 seen("old", Checks::Green, "CLEAN", false),
7712 seen("new", Checks::Green, "CLEAN", false),
7713 ],
7714 vec![(true, "")],
7715 );
7716 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7717 .await
7718 .unwrap();
7719 assert_eq!(
7720 forge.calls(),
7721 ["view", "poll", "view", "view", "merge", "view"]
7722 );
7723 assert_eq!(state.status, RunStatus::Merged);
7724 }
7725
7726 #[tokio::test]
7727 async fn a_superseded_public_head_owner_cannot_reenter_land() {
7728 let mut state = landing_state();
7729 state.pr_taken_over_by = Some("next".into());
7730 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7731 assert!(
7732 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7733 .await
7734 .is_err()
7735 );
7736 assert!(forge.calls().is_empty());
7737 }
7738
7739 #[tokio::test]
7740 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7741 let mut state = landing_state();
7742 let forge = Scripted::new(
7743 vec![
7744 seen("old", Checks::Green, "CLEAN", true),
7745 seen("old", Checks::Green, "CLEAN", true),
7747 seen("new", Checks::Pending, "BLOCKED", true),
7748 seen("new", Checks::Green, "CLEAN", true),
7749 ],
7750 vec![(true, "")],
7751 );
7752 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7753 head: "NEW".to_owned(),
7754 });
7755 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7756 .await
7757 .unwrap();
7758 assert_eq!(
7759 forge.calls(),
7760 [
7761 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7762 "view"
7763 ]
7764 );
7765 assert_eq!(state.status, RunStatus::Merged);
7766 }
7767
7768 #[tokio::test]
7769 async fn a_head_that_never_arrives_stops_naming_both_commits() {
7770 let mut state = landing_state();
7771 let forge = Scripted::new(
7772 vec![
7773 seen("old", Checks::Green, "CLEAN", true),
7774 seen("someone-elses", Checks::Green, "CLEAN", true),
7775 ],
7776 vec![],
7777 );
7778 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7779 head: "mine".to_owned(),
7780 });
7781 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7782 .await
7783 .unwrap();
7784 assert!(!forge.calls().contains(&"merge"));
7785 let why = state.merge.as_ref().unwrap().detail.clone();
7786 assert!(
7787 why.contains("mine") && why.contains("someone-elses"),
7788 "{why}"
7789 );
7790 assert_eq!(state.status, RunStatus::Blocked);
7791 }
7792
7793 #[tokio::test]
7794 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7795 let mut state = landing_state();
7796 let forge = Scripted::new(
7797 vec![
7798 seen("a", Checks::Green, "CLEAN", false),
7799 seen("a", Checks::Green, "CLEAN", false),
7800 seen("a", Checks::Pending, "BLOCKED", false),
7801 seen("a", Checks::Pending, "BLOCKED", false),
7802 seen("a", Checks::Green, "CLEAN", false),
7803 ],
7804 vec![(false, REFUSED), (true, "")],
7805 );
7806 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7807 .await
7808 .unwrap();
7809 assert_eq!(
7810 forge.calls(),
7811 [
7812 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7813 "view"
7814 ]
7815 );
7816 assert_eq!(state.status, RunStatus::Merged);
7817 }
7818
7819 #[tokio::test]
7820 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7821 let mut state = landing_state();
7822 let forge = Scripted::new(
7823 vec![
7824 seen("a", Checks::Green, "CLEAN", false),
7825 seen("a", Checks::Green, "BLOCKED", false),
7826 ],
7827 vec![(false, REFUSED), (false, REFUSED)],
7828 );
7829 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7830 .await
7831 .unwrap();
7832 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7834 let why = state.merge.as_ref().unwrap().detail.clone();
7835 assert!(
7836 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7837 "{why}"
7838 );
7839 assert_eq!(state.status, RunStatus::Blocked);
7840 }
7841
7842 #[test]
7843 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7844 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7845 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7846 assert_eq!(bound_head("old", "old", Some("new")), None);
7848 assert_eq!(bound_head("new", "old", Some("new")), None);
7850 assert_eq!(bound_head("new", "old", None), None);
7851 assert_eq!(bound_head("", "", None), None);
7853 assert_eq!(bound_head("", "", Some("new")), None);
7854 assert_eq!(bound_head("abc", "", None), None);
7855 }
7856
7857 fn view_json(head: &str) -> String {
7858 format!(
7859 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7860 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7861 "reviews":[],"comments":[]}}"#
7862 )
7863 }
7864
7865 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7866 format!(
7867 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7868 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7869 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7870 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7871 )
7872 }
7873
7874 #[test]
7875 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7876 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7879 assert_eq!(s.rollup_head, "old");
7880 assert_eq!(s.pr.checks, Checks::Red);
7881 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7882 assert_eq!(s.failing_urls.len(), 1);
7883 }
7884
7885 #[test]
7886 fn checks_come_from_the_node_not_the_view() {
7887 let view = view_json("new").replace(
7888 r#""reviews""#,
7889 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7890 );
7891 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7892 assert_eq!(s.pr.checks, Checks::Green);
7893 assert!(s.pr.failing.is_empty());
7894 assert_eq!(
7895 bound_head(&s.head, &s.rollup_head, Some("new")),
7896 Some("new")
7897 );
7898 }
7899
7900 #[test]
7901 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7902 for node in [
7903 None,
7904 Some("not json".to_owned()),
7905 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7906 Some(node_json("new", "SUCCESS", true)),
7907 ] {
7908 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7909 assert!(s.rollup_head.is_empty());
7910 assert_eq!(s.pr.checks, Checks::Unknown);
7911 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7912 }
7913 }
7914
7915 #[test]
7916 fn the_merge_command_is_pinned_to_the_observed_head() {
7917 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7918 let at = argv
7919 .iter()
7920 .position(|a| a == "--match-head-commit")
7921 .unwrap();
7922 assert_eq!(argv[at + 1], "deadbeef");
7923 }
7924
7925 #[tokio::test]
7926 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7927 let mut state = landing_state();
7928 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7931 stale.rollup_head = "old".to_owned();
7932 let forge = Scripted::new(
7933 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7934 vec![],
7935 );
7936 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7937 head: "new".to_owned(),
7938 });
7939 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7940 .await
7941 .unwrap();
7942 assert!(!forge.calls().contains(&"merge"));
7943 assert_eq!(state.status, RunStatus::Blocked);
7944 let why = state.merge.as_ref().unwrap().detail.clone();
7945 assert!(why.contains("new") && why.contains("old"), "{why}");
7946 }
7947
7948 #[test]
7949 fn a_refusal_read_against_another_commits_checks_is_pending() {
7950 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7951 after.rollup_head = "old".to_owned();
7952 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7953 }
7954
7955 #[tokio::test]
7956 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7957 let mut state = landing_state();
7958 let forge = Scripted::new(
7959 vec![seen("a", Checks::Red, "CLEAN", false)],
7960 vec![(true, "")],
7961 );
7962 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7963 .await
7964 .unwrap();
7965 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7966 assert_eq!(state.status, RunStatus::Merged);
7967 }
7968
7969 #[tokio::test]
7970 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7971 let mut state = landing_state();
7972 let forge = Scripted::new(
7973 vec![
7974 seen("a", Checks::Green, "CLEAN", false),
7975 seen("a", Checks::Green, "CLEAN", false),
7976 seen("b", Checks::Green, "BLOCKED", false),
7978 seen("b", Checks::Green, "CLEAN", false),
7979 ],
7980 vec![(false, REFUSED), (true, "")],
7981 );
7982 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7983 .await
7984 .unwrap();
7985 assert_eq!(
7986 forge.calls(),
7987 [
7988 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7989 ]
7990 );
7991 assert_eq!(state.status, RunStatus::Merged);
7992 }
7993
7994 fn merged_view(head: &str) -> Seen {
7995 let mut m = seen(head, Checks::Green, "CLEAN", false);
7996 m.pr.state = PrLifecycle::Merged;
7997 m
7998 }
7999
8000 fn has(argv: &[String], flag: &str) -> bool {
8001 argv.iter().any(|a| a == flag)
8002 }
8003
8004 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
8005 let at = argv.iter().position(|a| a == flag)?;
8006 argv.get(at + 1).map(String::as_str)
8007 }
8008
8009 const URL: &str = "https://github.com/o/r/pull/42";
8010
8011 #[tokio::test]
8012 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
8013 let mut state = landing_state();
8014 let forge = Scripted::new(
8015 vec![
8016 seen("abc", Checks::Green, "CLEAN", false),
8017 seen("abc", Checks::Green, "CLEAN", false),
8018 ],
8019 vec![(true, "")],
8020 );
8021 land_with(&mut state, URL, &forge).await.unwrap();
8022 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8023 let argv = &forge.argvs()[0];
8024 assert!(has(argv, "--squash") && has(argv, "--subject"));
8025 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
8026 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
8027 assert_eq!(state.status, RunStatus::Merged);
8028 assert!(state.land_armed_head.is_none());
8029 }
8030
8031 #[tokio::test]
8032 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
8033 let mut state = landing_state();
8034 state.land_armed_head = Some("a".to_owned());
8035 let forge = Scripted::new(
8036 vec![seen("a", Checks::Green, "CLEAN", false)],
8037 vec![(true, ""), (true, "")],
8038 );
8039 land_with(&mut state, URL, &forge).await.unwrap();
8040 let argvs = forge.argvs();
8041 assert!(has(&argvs[0], "--disable-auto"));
8042 assert!(!has(&argvs[1], "--auto"));
8043 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
8044 assert_eq!(state.status, RunStatus::Merged);
8045 assert!(state.land_armed_head.is_none());
8046 }
8047
8048 #[tokio::test]
8049 async fn an_approval_never_carries_over_to_a_new_head() {
8050 crate::run::pin_test_home();
8051 let mut state = run_state();
8052 state.config.graph.land_approval = true;
8053 let pr = green_pr();
8054 let store = ask::Questions::open();
8055
8056 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8057 .await
8058 .unwrap();
8059 let mut q = store
8060 .list()
8061 .into_iter()
8062 .find(|q| q.run == state.id)
8063 .unwrap();
8064 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8065 store.put(&mut q).unwrap();
8066 assert_eq!(
8067 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8068 .await
8069 .unwrap(),
8070 ApprovalGate::Approved,
8071 "the same head keeps its approval"
8072 );
8073
8074 assert_eq!(
8076 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8077 .await
8078 .unwrap(),
8079 ApprovalGate::Pending
8080 );
8081 let all: Vec<_> = store
8082 .list()
8083 .into_iter()
8084 .filter(|q| q.run == state.id)
8085 .collect();
8086 assert_eq!(all.len(), 2);
8087
8088 state.land_approval = None;
8090 assert_eq!(
8091 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8092 .await
8093 .unwrap(),
8094 ApprovalGate::Pending
8095 );
8096 let open = store
8097 .list()
8098 .into_iter()
8099 .filter(|q| q.run == state.id && q.status.open())
8100 .count();
8101 assert_eq!(open, 1, "the superseded question was retired");
8102 }
8103
8104 #[tokio::test]
8105 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8106 let mut state = landing_state();
8107 let forge = Scripted::new(
8108 vec![
8109 seen("a", Checks::Green, "CLEAN", false),
8110 seen("b", Checks::Green, "CLEAN", false),
8112 ],
8113 vec![(true, "")],
8114 );
8115 land_with(&mut state, URL, &forge).await.unwrap();
8116 let argvs = forge.argvs();
8117 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8118 assert!(!has(&argvs[0], "--auto"));
8119 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8120 assert_eq!(state.status, RunStatus::Merged);
8121 }
8122
8123 fn passing(label: &str) -> CheckInfo {
8124 CheckInfo {
8125 label: label.to_owned(),
8126 verdict: Verdict::Pass,
8127 required: Some(false),
8128 run: None,
8129 }
8130 }
8131
8132 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8133 (label.to_owned(), run.map(str::to_owned))
8134 }
8135
8136 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8137 LandReruns {
8138 head: head.to_owned(),
8139 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8140 }
8141 }
8142
8143 #[test]
8144 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8145 let f = [failing("test (macos)", Some("7"))];
8146 assert_eq!(
8147 rerun_step(&f, None, "h", 100),
8148 RerunStep::Rerun(vec!["7".to_owned()])
8149 );
8150 let rec = reran("h", &[("7", 100)]);
8151 let grace = crate::release_watch::RERUN_GRACE;
8152 assert_eq!(
8153 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8154 RerunStep::Grace
8155 );
8156 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8157 }
8158
8159 #[test]
8160 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8161 let f = [failing("a", Some("7")), failing("ci/external", None)];
8162 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8163 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8164 }
8165
8166 #[test]
8167 fn a_moved_head_forgets_the_reruns() {
8168 let f = [failing("a", Some("7"))];
8169 let rec = reran("old", &[("7", 100)]);
8170 assert_eq!(
8171 rerun_step(&f, Some(&rec), "new", 101),
8172 RerunStep::Rerun(vec!["7".to_owned()])
8173 );
8174 }
8175
8176 #[test]
8177 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8178 let f = [
8179 failing("a", Some("7")),
8180 failing("b", Some("7")),
8181 failing("c", Some("8")),
8182 ];
8183 assert_eq!(
8184 rerun_step(&f, None, "h", 0),
8185 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8186 );
8187 let rec = reran("h", &[("7", 0)]);
8188 assert_eq!(
8189 rerun_step(&f, Some(&rec), "h", 1),
8190 RerunStep::Rerun(vec!["8".to_owned()])
8191 );
8192 }
8193
8194 #[test]
8195 fn only_blocking_failures_are_candidates_for_a_rerun() {
8196 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8197 label: label.to_owned(),
8198 verdict,
8199 required,
8200 run: Some(run.to_owned()),
8201 };
8202 let got = rerunnable(&[
8203 check("passed", Verdict::Pass, Some(true), "1"),
8204 check("optional", Verdict::Fail, Some(false), "2"),
8205 check("required", Verdict::Fail, Some(true), "3"),
8206 check("unknown", Verdict::Fail, None, "4"),
8207 ]);
8208 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8209 assert_eq!(labels, ["required", "unknown"]);
8210 }
8211
8212 fn red(head: &str, run: Option<&str>) -> Seen {
8213 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8214 s.pr.failing = vec!["test (macos)".to_owned()];
8215 s.contexts = vec![CheckInfo {
8216 label: "test (macos)".to_owned(),
8217 verdict: Verdict::Fail,
8218 required: Some(true),
8219 run: run.map(str::to_owned),
8220 }];
8221 s
8222 }
8223
8224 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8225
8226 #[tokio::test]
8227 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8228 let mut state = landing_state();
8229 let forge = Scripted::new(
8230 vec![
8231 red("a", Some("7")),
8232 seen("a", Checks::Pending, "BLOCKED", false),
8233 seen("a", Checks::Green, "CLEAN", false),
8234 ],
8235 vec![(true, "")],
8236 );
8237 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8238 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8239 assert!(!forge.calls().contains(&"fix"));
8240 assert_eq!(state.status, RunStatus::Merged);
8241 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8242 let argvs = forge.argvs();
8243 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8244 assert!(
8245 state
8246 .events
8247 .iter()
8248 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8249 );
8250 }
8251
8252 #[tokio::test]
8253 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8254 let mut state = landing_state();
8255 let forge = Scripted::new(
8256 vec![
8257 red("a", Some("7")),
8258 seen("a", Checks::Green, "CLEAN", false),
8259 ],
8260 vec![(true, "")],
8261 );
8262 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8263 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8265 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8266 let calls = forge.calls();
8267 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8268 let fix_at = calls.iter().position(|c| *c == "fix");
8269 assert!(fix_at.is_none_or(|f| f > rerun_at));
8270 }
8271
8272 #[tokio::test]
8273 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8274 let mut state = landing_state();
8275 let mut v = red("a", Some("7"));
8276 v.pr.checks = Checks::Pending;
8277 v.contexts.push(CheckInfo {
8278 label: "slow".to_owned(),
8279 verdict: Verdict::Pending,
8280 required: Some(true),
8281 run: Some("8".to_owned()),
8282 });
8283 let forge = Scripted::new(
8284 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8285 vec![(true, "")],
8286 );
8287 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8288 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8289 assert_eq!(state.status, RunStatus::Merged);
8290 }
8291
8292 #[test]
8293 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8294 let mut v = red("a", Some("7"));
8295 v.contexts.push(CheckInfo {
8296 label: "same run, still going".to_owned(),
8297 verdict: Verdict::Pending,
8298 required: Some(true),
8299 run: Some("7".to_owned()),
8300 });
8301 assert!(rerunnable(&v.contexts).is_empty());
8302 }
8303
8304 #[tokio::test]
8305 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8306 let mut state = landing_state();
8307 state.config.graph.land_rounds = 0;
8308 let forge = Scripted::new(
8309 vec![
8310 red("a", Some("7")),
8311 seen("a", Checks::Green, "CLEAN", false),
8312 ],
8313 vec![(true, "")],
8314 );
8315 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8316 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8317 assert_eq!(state.status, RunStatus::Merged);
8318 assert!(!forge.calls().contains(&"fix"));
8319 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8320 }
8321
8322 #[tokio::test]
8323 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8324 let mut state = landing_state();
8325 state.config.graph.land_rounds = 1;
8326 let forge = Scripted::new(
8327 vec![
8328 seen("a", Checks::Green, "CLEAN", true),
8329 red("b", Some("7")),
8330 seen("b", Checks::Green, "CLEAN", false),
8331 ],
8332 vec![(true, "")],
8333 );
8334 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8335 head: "b".to_owned(),
8336 });
8337 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8338 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8339 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8340 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8341 assert_eq!(state.status, RunStatus::Merged);
8342 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8343 assert_eq!(
8344 value_of(&forge.argvs()[0], "--match-head-commit"),
8345 Some("b")
8346 );
8347 }
8348
8349 #[tokio::test]
8350 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8351 let mut state = landing_state();
8352 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8353 *forge.rerun_fails.lock().unwrap() = true;
8354 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8355 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8356 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8357 assert_eq!(
8358 forge.calls().iter().filter(|c| **c == "fix").count(),
8359 1,
8360 "{:?}",
8361 forge.calls()
8362 );
8363 }
8364
8365 #[tokio::test]
8366 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8367 let mut state = landing_state();
8368 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8369 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8371 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8372 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8373 assert!(forge.reruns.lock().unwrap().is_empty());
8374 }
8375
8376 #[tokio::test]
8377 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8378 let mut state = landing_state();
8379 state.config.graph.rerun_flaky_checks = false;
8380 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8381 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8382 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8383 assert!(forge.reruns.lock().unwrap().is_empty());
8384 assert!(forge.calls().contains(&"fix"));
8385
8386 let mut state = landing_state();
8387 let forge = Scripted::new(vec![red("a", None)], vec![]);
8388 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8389 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8390 assert!(forge.reruns.lock().unwrap().is_empty());
8391 assert!(forge.calls().contains(&"fix"));
8392 }
8393
8394 fn names(xs: &[&str]) -> BTreeSet<String> {
8395 xs.iter().map(|x| (*x).to_owned()).collect()
8396 }
8397
8398 #[test]
8399 fn a_required_check_the_rollup_never_listed_is_named() {
8400 let req = names(&["build"]);
8401 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8402 assert!(why.contains("never reported: build"), "{why}");
8403 assert!(!why.contains("probably waiting for a review"), "{why}");
8404 }
8405
8406 #[test]
8407 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8408 let why = waiting_on("BLOCKED", &[passing("review")], None);
8409 assert!(why.contains("could not be read"), "{why}");
8410 assert!(!why.contains("probably waiting for a review"), "{why}");
8411 }
8412
8413 #[test]
8414 fn all_required_reported_keeps_the_review_guess() {
8415 let req = names(&["build"]);
8416 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8417 assert!(why.contains("probably waiting for a review"), "{why}");
8418 assert!(!why.contains("never reported"), "{why}");
8419 }
8420
8421 #[test]
8422 fn required_names_match_the_rollup_ignoring_case_only() {
8423 let req = names(&["Build"]);
8424 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8425 assert!(!why.contains("never reported"), "{why}");
8426 }
8427
8428 #[test]
8429 fn required_contexts_are_read_from_protection_and_rulesets() {
8430 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8431 assert_eq!(
8432 parse_classic_required(classic),
8433 Some(names(&["build", "lint"]))
8434 );
8435 let rules = r#"[{"type":"pull_request","parameters":{}},
8436 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8437 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8438 assert_eq!(parse_ruleset_required("nope"), None);
8439 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8440 }
8441
8442 #[test]
8443 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8444 let shown = BTreeSet::new();
8445 let ok = seen("a", Checks::Green, "CLEAN", false);
8446 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8447 assert!(guard(Some(&ok)));
8448 assert!(!guard(None));
8449 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
8450 let mut stale = ok.clone();
8451 stale.rollup_head = "old".to_owned();
8452 assert!(!guard(Some(&stale)));
8453 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
8454 assert!(!guard(Some(&merged_view("a"))));
8455 }
8456
8457 #[test]
8458 fn the_rollup_node_carries_whether_each_check_is_required() {
8459 let node = node_json("new", "SUCCESS", false)
8460 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
8461 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
8462 assert_eq!(s.contexts.len(), 1);
8463 assert_eq!(s.contexts[0].required, Some(true));
8464 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
8465 assert_eq!(s.contexts[0].required, None);
8466 }
8467
8468 #[tokio::test]
8469 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
8470 let mut state = landing_state();
8471 state.land_armed_head = Some("a".to_owned());
8472 let forge = Scripted::new(
8473 vec![seen("a", Checks::Green, "CLEAN", true)],
8474 vec![(false, "disable exploded")],
8475 );
8476 land_with(&mut state, URL, &forge).await.unwrap();
8477 assert!(!forge.calls().contains(&"fix"));
8478 assert_eq!(state.status, RunStatus::Blocked);
8479 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
8480 let why = state.merge.as_ref().unwrap().detail.clone();
8481 assert!(why.contains("disable exploded"), "{why}");
8482 }
8483}