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 merged {
3833 state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
3834 }
3835 if let Some(armed) = state.land_armed_head.take() {
3840 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3841 let msg = format!(
3842 "{} merged on {} but the owner approved {armed}; review what landed",
3843 pr.url, seen.head
3844 );
3845 tracing::warn!("{msg}");
3846 state.event("land", msg);
3847 crate::notices::raise_with(
3851 crate::notices::Notice::warn(
3852 &format!("merged-unapproved-head:{}", state.id),
3853 "A pull request merged on a commit the owner did not approve; \
3854 review what landed",
3855 )
3856 .link(crate::notices::Link::Run {
3857 id: state.id.clone(),
3858 }),
3859 &state.config.notify,
3860 );
3861 }
3862 }
3863 state.status = if merged {
3864 RunStatus::Merged
3865 } else {
3866 RunStatus::Ready
3867 };
3868 let detail = if merged {
3869 format!("{} was merged", pr.url)
3870 } else {
3871 format!("{} was closed without merging", pr.url)
3872 };
3873 state.merge = Some(MergeOutcome {
3874 mode: MergeMode::Pr,
3875 ok: merged,
3876 detail: detail.clone(),
3877 empty: false,
3878 });
3879 state.event("land", detail);
3880 state.save()?;
3881 write_pr_state_through(state, pr.state);
3882 return Ok(pr);
3883 }
3884 Step::Merge => {
3885 state.check_delivery_evidence(&seen.head)?;
3886 let subject = merge_subject(
3887 crate::graph::landing_title(state, &seen.title),
3888 &crate::graph::landing_subject_source(state),
3889 );
3890 let contested = contested_to_ask(state);
3897 if state.config.graph.land_approval
3898 || contested.is_some()
3899 || repair_available(state)
3900 || !state.merge_repairs.is_empty()
3901 {
3902 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3903 .await?
3904 {
3905 ApprovalGate::Approved => {}
3906 ApprovalGate::Repair => {
3907 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3910 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3911 state.save()?;
3912 }
3913 return Ok(pr);
3914 }
3915 ApprovalGate::Held => {
3916 if owner_stop(state, &mut pr, forge).await? {
3919 return Ok(pr);
3920 }
3921 stop(
3922 state,
3923 &repo,
3924 &pr,
3925 "the owner did not approve the merge (held or unanswered)",
3926 )
3927 .await?;
3928 return Ok(pr);
3929 }
3930 ApprovalGate::Pending => {
3938 state.end_pass_parked(crate::run::ParkCause::Question);
3939 state.parked = true;
3940 state.event(
3941 "land",
3942 "parked awaiting merge approval - resumes once answered",
3943 );
3944 state.save()?;
3945 return Ok(pr);
3946 }
3947 }
3948 }
3949 let observed_head = seen.head.clone();
3960 {
3963 let fresh = forge.view(&repo, pr_url).await.ok();
3964 if !direct_merge_is_safe(
3965 fresh.as_ref(),
3966 &observed_head,
3967 &shown,
3968 round,
3969 budget,
3970 waited,
3971 ) {
3972 if waited >= WAIT_CEILING {
3973 let why = "the pull request did not settle on the approved head \
3974 before it could be merged";
3975 stop(state, &repo, &pr, why).await?;
3976 return Ok(pr);
3977 }
3978 state.event(
3979 "land",
3980 "the pull request changed before merging; looking again",
3981 );
3982 waited += POLL;
3983 forge.poll().await;
3984 continue;
3985 }
3986 }
3987 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3988 let out = {
3989 let merge_lock = repo_merge_lock(&repo);
3990 let _merge_slot = merge_lock.lock().await;
3991 crate::pr_head::ensure_owner(state)?;
3992 if let Some(task_id) = state
3993 .origin
3994 .as_ref()
3995 .and_then(|origin| origin.task.as_ref())
3996 {
3997 let task = crate::queue::Queue::open()
3998 .get(task_id)
3999 .context("cannot verify the task owner immediately before merge")?;
4000 if task.runs.last() != Some(&state.id)
4001 || task.operator_held()
4002 || task.status == crate::queue::TaskStatus::Done
4003 {
4004 bail!(
4005 "task ownership or an operator hold superseded this merge approval"
4006 );
4007 }
4008 }
4009 if let Some(binding) = &state.land_approval {
4010 let current = ask::Questions::open().get(&binding.question)?;
4011 if merge_consent_superseded(state, ¤t)?
4012 || current.run != state.id
4013 || !binding.head.eq_ignore_ascii_case(&observed_head)
4014 || approval(current.resolution().as_deref()) != Approval::Merge
4015 || current.merge_target.as_ref().is_some_and(|t| {
4016 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4017 })
4018 {
4019 bail!(
4020 "merge decision is stale or no longer authorizes the reviewed head"
4021 );
4022 }
4023 }
4024 forge.merge(&repo, &argv).await?
4025 };
4026 if out.0 {
4027 let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4033 if c.pr.state == PrLifecycle::Merged {
4034 state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4035 true
4036 } else {
4037 false
4038 }
4039 });
4040 if !confirmed {
4041 if waited >= WAIT_CEILING {
4042 let why = "the merge request succeeded but the pull request \
4043 could not be confirmed merged after waiting";
4044 stop(state, &repo, &pr, why).await?;
4045 return Ok(pr);
4046 }
4047 state.event(
4048 "land",
4049 "merge accepted but the pull request is not confirmed merged yet; waiting",
4050 );
4051 state.save()?;
4052 waited += POLL;
4053 forge.poll().await;
4054 continue;
4055 }
4056 pr.state = PrLifecycle::Merged;
4057 state.status = RunStatus::Merged;
4058 state.merge = Some(MergeOutcome {
4059 mode: MergeMode::Pr,
4060 ok: true,
4061 detail: format!("gh {}", argv.join(" ")),
4062 empty: false,
4063 });
4064 if let Some(pr_record) = state.pr.as_mut() {
4069 pr_record.state = pr.state.as_str().to_owned();
4070 }
4071 state.event("land", format!("merged {} as `{subject}`", pr.url));
4072 announce_red_merge(state, &pr).await;
4073 state.save()?;
4074 write_pr_state_through(state, pr.state);
4075 return Ok(pr);
4076 }
4077 let after_seen = forge.view(&repo, pr_url).await.ok();
4078 let after = after_seen.as_ref().map(|s| s.pr.state);
4079 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4080 state.pr_merged_head = after_seen
4081 .as_ref()
4082 .map(|s| s.head.clone())
4083 .filter(|h| !h.is_empty());
4084 pr.state = PrLifecycle::Merged;
4085 state.status = RunStatus::Merged;
4086 state.merge = Some(outcome);
4087 if let Some(pr_record) = state.pr.as_mut() {
4088 pr_record.state = pr.state.as_str().to_owned();
4089 }
4090 state.event("land", format!("merged {} as `{subject}`", pr.url));
4091 announce_red_merge(state, &pr).await;
4092 state.save()?;
4093 write_pr_state_through(state, pr.state);
4094 return Ok(pr);
4095 }
4096 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4097 match verdict {
4098 Refused::Final => {
4099 let merge_state = after_seen
4100 .as_ref()
4101 .map(|s| s.merge_state.as_str())
4102 .filter(|m| !m.is_empty())
4103 .unwrap_or("unknown");
4104 let why = format!(
4108 "the merge was refused: {} (merge state: {merge_state})",
4109 out.1
4110 );
4111 stop(state, &repo, &pr, &why).await?;
4112 return Ok(pr);
4113 }
4114 verdict => {
4115 if waited >= WAIT_CEILING {
4120 let why = format!(
4121 "the merge was still refused after {} minutes: {}",
4122 WAIT_CEILING.as_secs() / 60,
4123 out.1
4124 );
4125 stop(state, &repo, &pr, &why).await?;
4126 return Ok(pr);
4127 }
4128 if verdict == Refused::Recheck {
4129 rechecked = true;
4130 }
4131 state.event(
4132 "land",
4133 "merge refused while the branch policy is not satisfied yet; waiting",
4134 );
4135 state.save()?;
4136 waited += POLL;
4137 forge.poll().await;
4138 }
4139 }
4140 }
4141 Step::Rebase => {
4142 if rebases >= budget {
4148 let why = format!(
4149 "the base moved under this branch {budget} time(s) and it still does \
4150 not merge; rebasing again would only race it"
4151 );
4152 stop(state, &repo, &pr, &why).await?;
4153 return Ok(pr);
4154 }
4155 rebases += 1;
4156 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4157 stop(
4158 state,
4159 &repo,
4160 &pr,
4161 "the pull request conflicts and this run has no winning branch to rebase",
4162 )
4163 .await?;
4164 return Ok(pr);
4165 };
4166 let base = state.base_branch.clone();
4167 state.event(
4168 "land",
4169 format!("{} no longer merges; rebasing onto {base}", pr.url),
4170 );
4171 state.save()?;
4172
4173 let remote = state.config.merge.remote.clone();
4177 git::fetch(&repo, &remote, &base).await.ok();
4178 if owner_stop(state, &mut pr, forge).await? {
4179 return Ok(pr);
4180 }
4181 let scratch = state.dir().join("rebase");
4182 let onto = format!("{remote}/{base}");
4183 let rebased =
4184 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4185 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4186 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4187 Err(e) => Err(e),
4188 };
4189 if owner_stop(state, &mut pr, forge).await? {
4190 return Ok(pr);
4191 }
4192 match rebased {
4193 Ok(None) => {
4194 let pushed = {
4195 let merge_lock = repo_merge_lock(&repo);
4196 let _merge_slot = merge_lock.lock().await;
4197 if let Some(head) = &state.pr_head {
4198 git::push_as(
4199 &repo,
4200 &state.config.merge.remote,
4201 &branch,
4202 head,
4203 state
4204 .pr_pushed_head
4205 .as_deref()
4206 .context("missing public head lease")?,
4207 )
4208 .await?
4209 } else {
4210 git::push_pinned(
4211 &repo,
4212 &state.config.merge.remote,
4213 &branch,
4214 &seen.head,
4215 )
4216 .await?
4217 }
4218 };
4219 if !pushed.ok() {
4220 let why = format!(
4221 "rebased {branch} but could not push it: {}",
4222 pushed.stderr.trim()
4223 );
4224 stop(state, &repo, &pr, &why).await?;
4225 return Ok(pr);
4226 }
4227 let head =
4230 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4231 Ok(head) => head,
4232 Err(e) => {
4233 let why = format!(
4234 "rebased and pushed {branch} but could not read the pushed \
4235 commit: {e:#}"
4236 );
4237 stop(state, &repo, &pr, &why).await?;
4238 return Ok(pr);
4239 }
4240 };
4241 crate::graph::refresh_reviewed_commits(state, &branch).await;
4242 state.pr_pushed_head = Some(head.clone());
4243 awaiting_head = Some(head);
4244 rechecked = false;
4245 state.event("land", format!("rebased {branch} onto {base}"));
4246 state.save()?;
4247 waited = Duration::ZERO;
4250 tokio::time::sleep(POLL).await;
4251 }
4252 Ok(Some(conflict)) => {
4255 let why = format!(
4256 "{} conflicts with {base} and the rebase did not apply: {}",
4257 pr.url,
4258 conflict.chars().take(600).collect::<String>()
4259 );
4260 stop(state, &repo, &pr, &why).await?;
4261 return Ok(pr);
4262 }
4263 Err(e) => {
4264 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4265 stop(state, &repo, &pr, &why).await?;
4266 return Ok(pr);
4267 }
4268 }
4269 }
4270 Step::GiveUp { reason } => {
4271 stop(state, &repo, &pr, &reason).await?;
4272 return Ok(pr);
4273 }
4274 Step::Fix { reason } => {
4275 round += 1;
4276 waited = Duration::ZERO;
4277 for c in &pr.review_comments {
4278 shown.insert(c.body.clone());
4279 }
4280 state.event("land", format!("round {round}: {reason}"));
4281 state.save()?;
4282
4283 let logs = failing_logs(&repo, &seen.failing_urls).await;
4284 let was_red = pr.checks == Checks::Red;
4285 if owner_stop(state, &mut pr, forge).await? {
4286 return Ok(pr);
4287 }
4288 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4289 if owner_stop(state, &mut pr, forge).await? {
4290 return Ok(pr);
4291 }
4292 match fixed {
4293 Fixed::Committed { head } => {
4294 state.pr_pushed_head = Some(head.clone());
4297 awaiting_head = Some(head);
4298 rechecked = false;
4299 waited = Duration::ZERO;
4300 forge.poll().await;
4301 }
4302 Fixed::Declined if was_red => {
4303 let why = format!(
4304 "the fixer produced no commit while {} check(s) were failing \
4305 ({}); stopping instead of looping on an unchanged tree",
4306 pr.failing.len(),
4307 pr.failing.join(", ")
4308 );
4309 stop(state, &repo, &pr, &why).await?;
4310 return Ok(pr);
4311 }
4312 Fixed::Declined => state.event(
4317 "land",
4318 format!("round {round}: fixer declined the comments, nothing committed"),
4319 ),
4320 Fixed::Failed(why) => {
4321 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4322 return Ok(pr);
4323 }
4324 }
4325 state.save()?;
4326 }
4327 }
4328 }
4329}
4330
4331#[derive(Clone)]
4335struct Seen {
4336 pr: PrState,
4337 title: String,
4338 failing_urls: Vec<(String, String)>,
4339 head: String,
4341 rollup_head: String,
4344 merge_state: String,
4347 contexts: Vec<CheckInfo>,
4349 base: String,
4351}
4352
4353#[derive(Clone)]
4355struct CheckInfo {
4356 label: String,
4357 verdict: Verdict,
4358 required: Option<bool>,
4359 run: Option<String>,
4361}
4362
4363#[derive(Debug, Clone, PartialEq, Eq)]
4365enum RerunStep {
4366 Rerun(Vec<String>),
4368 Grace,
4370 Fix,
4372}
4373
4374fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4378 contexts
4379 .iter()
4380 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4381 .filter(|c| {
4383 c.run.as_ref().is_none_or(|r| {
4384 !contexts.iter().any(|o| {
4385 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4386 && o.run.as_ref() == Some(r)
4387 })
4388 })
4389 })
4390 .map(|c| (c.label.clone(), c.run.clone()))
4391 .collect()
4392}
4393
4394fn rerun_step(
4396 failing: &[(String, Option<String>)],
4397 record: Option<&LandReruns>,
4398 head: &str,
4399 now: i64,
4400) -> RerunStep {
4401 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4404 return RerunStep::Fix;
4405 }
4406 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4408 let mut fresh: Vec<String> = Vec::new();
4409 let mut recent = false;
4410 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4411 match record.and_then(|r| r.runs.get(run)) {
4412 None => {
4413 if !fresh.iter().any(|f| f == run) {
4414 fresh.push(run.to_owned());
4415 }
4416 }
4417 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4418 Some(_) => {}
4419 }
4420 }
4421 if !fresh.is_empty() {
4422 RerunStep::Rerun(fresh)
4423 } else if recent {
4424 RerunStep::Grace
4425 } else {
4426 RerunStep::Fix
4427 }
4428}
4429
4430async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4434 let view = gh(
4435 repo,
4436 &[
4437 "pr".to_owned(),
4438 "view".to_owned(),
4439 pr_url.to_owned(),
4440 "--json".to_owned(),
4441 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4442 .to_owned(),
4443 ],
4444 )
4445 .await?;
4446 if !view.0 {
4447 bail!("gh pr view {pr_url}: {}", view.1);
4448 }
4449 let number = parse_pr(&view.1)?.number;
4450 let node = last_commit_node(repo, number).await;
4451 let mut seen = seen_from(&view.1, node.as_deref())?;
4452
4453 let inline = gh(
4454 repo,
4455 &[
4456 "api".to_owned(),
4457 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4458 ],
4459 )
4460 .await?;
4461 if inline.0 {
4462 match parse_inline_comments(&inline.1) {
4463 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4464 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4467 }
4468 } else {
4469 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4470 }
4471 Ok(seen)
4472}
4473
4474fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4485 let mut pr = parse_pr(view_json)?;
4486 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4487
4488 let mut rollup_head = String::new();
4489 let mut failing_urls = Vec::new();
4490 let mut contexts = Vec::new();
4491 let mut checks = Checks::Unknown;
4492 let mut failing = Vec::new();
4493 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4494 (checks, failing) = rollup_verdict(&rollup);
4495 failing_urls = rollup
4496 .iter()
4497 .filter(|c| c.verdict() == Verdict::Fail)
4498 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4499 .collect();
4500 contexts = rollup
4501 .iter()
4502 .map(|c| CheckInfo {
4503 label: c.label(),
4504 verdict: c.verdict(),
4505 required: c.is_required,
4506 run: c.url().and_then(run_of),
4507 })
4508 .collect();
4509 rollup_head = oid;
4510 }
4511 pr.checks = checks;
4512 pr.failing = failing;
4513
4514 Ok(Seen {
4515 pr,
4516 title: raw.title,
4517 failing_urls,
4518 head: raw.head_ref_oid,
4519 rollup_head,
4520 merge_state: raw.merge_state_status,
4521 contexts,
4522 base: raw.base_ref_name,
4523 })
4524}
4525
4526fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4529 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4530 if v.get("errors").is_some_and(|e| !e.is_null()) {
4531 return None;
4532 }
4533 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4534 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4535 let contexts = commit.pointer("/statusCheckRollup/contexts");
4536 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4537 return Some((oid.to_owned(), Vec::new()));
4539 };
4540 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4541 return None;
4542 }
4543 let nodes = contexts.get("nodes")?.as_array()?;
4544 let rollup = nodes
4545 .iter()
4546 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4547 .collect::<Result<Vec<_>, _>>()
4548 .ok()?;
4549 Some((oid.to_owned(), rollup))
4550}
4551
4552async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4555 let out = gh(
4556 repo,
4557 &[
4558 "api".to_owned(),
4559 "graphql".to_owned(),
4560 "-F".to_owned(),
4561 "owner={owner}".to_owned(),
4562 "-F".to_owned(),
4563 "repo={repo}".to_owned(),
4564 "-F".to_owned(),
4565 format!("number={number}"),
4566 "-f".to_owned(),
4567 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4568 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4569 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4570 ... on CheckRun{name status conclusion detailsUrl \
4571 isRequired(pullRequestNumber:$number)} \
4572 ... on StatusContext{context state targetUrl \
4573 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4574 .to_owned(),
4575 ],
4576 )
4577 .await
4578 .ok()?;
4579 out.0.then_some(out.1)
4580}
4581
4582#[doc(hidden)]
4584#[derive(Debug, PartialEq)]
4585pub enum Fixed {
4586 Committed {
4588 head: String,
4590 },
4591 Declined,
4593 Failed(String),
4595}
4596
4597#[doc(hidden)]
4603pub async fn fix_round(
4604 state: &mut RunState,
4605 pr: &PrState,
4606 round: usize,
4607 budget: usize,
4608 reason: &str,
4609 logs: &str,
4610) -> Result<Fixed> {
4611 let winner = state
4612 .winner()
4613 .cloned()
4614 .context("landing needs a winning candidate; none is recorded on this run")?;
4615 let roles = state
4616 .config
4617 .resolve_roles()
4618 .context("resolve the roster for the fix round")?;
4619 let attempts = crate::fixer::attempts(state, &roles, &winner);
4623 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4624
4625 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4626 let mut artifacts = agent::artifacts_dir(&state.dir());
4627 if let Some(request) = state
4628 .merge_repairs
4629 .last()
4630 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4631 {
4632 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4633 }
4634 let prompt = if state.config.cache_dir().is_some() {
4635 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4636 } else {
4637 prompt
4638 };
4639 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4643 let mut last = None;
4647 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4648 if owner_requested_stop(state)? {
4649 return Ok(Fixed::Declined);
4650 }
4651 let mut seat = seat_of(state, &seat_key, &spec.id);
4652 let stem = if i == 0 {
4653 format!("land-{round}")
4654 } else {
4655 format!("land-{round}-{}", spec.id)
4656 };
4657 let out = agent::invoke(
4658 &spec,
4659 &mut seat,
4660 &Invocation {
4661 scope: crate::agent::InvocationScope::Repository,
4662 coordination: state.config.coordination.as_ref(),
4663 fetched_base: state.fetched_base.as_ref(),
4664 cwd: &winner.worktree,
4665 prompt: &prompt,
4666 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4667 allow_write: true,
4668 unsandboxed: false,
4669 sessions: state.config.graph.sessions,
4670 artifacts: &artifacts,
4671 stem: &stem,
4672 run: &state.id,
4673 node: "land",
4674 cache_dir: state.config.cache_dir().as_deref(),
4675 attachments: &[],
4676 writable: &[],
4677 },
4678 )
4679 .await;
4680 state.seats.insert(seat.key.clone(), seat);
4681 if let Some(next) = ids.get(i + 1)
4682 && agent::chain_advances(&out)
4683 {
4684 let (class, why) = crate::fixer::failure_of(&out);
4685 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4686 continue;
4687 }
4688 last = Some(out);
4689 break;
4690 }
4691 let out = last.expect("the fixer chain always has an entry");
4692
4693 match out {
4694 Ok(o) if o.quota_exhausted() => {
4695 return Ok(Fixed::Failed(
4696 "rate limited (quota); the fixer could not run".to_owned(),
4697 ));
4698 }
4699 Ok(o) if !o.usable() => {
4700 return Ok(Fixed::Failed(format!(
4701 "the fixer produced nothing usable ({})",
4702 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
4703 )));
4704 }
4705 Ok(_) => {}
4706 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4707 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4708 }
4709
4710 if owner_requested_stop(state)? {
4711 return Ok(Fixed::Declined);
4712 }
4713
4714 let wait = crate::repo_lock::WaitPolicy::new(
4717 Duration::from_secs(state.config.graph.timeout_resource_wait),
4718 || false,
4719 );
4720 let coordination = crate::repo_lock::SeatLease::operation_with(
4721 &winner.worktree,
4722 state.config.coordination.as_ref(),
4723 &wait,
4724 )
4725 .await?;
4726 match git::rescue_commit(
4727 &winner.worktree,
4728 &format!("magi: land round {round} fixes (uncommitted work)"),
4729 )
4730 .await
4731 {
4732 Ok(r) => state.note_withheld("land", &r.withheld),
4733 Err(e)
4734 if state
4735 .merge_repairs
4736 .last()
4737 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4738 {
4739 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4740 }
4741 Err(_) => {}
4742 }
4743 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4744 drop(coordination);
4745 if after == before {
4746 return Ok(Fixed::Declined);
4747 }
4748
4749 if owner_requested_stop(state)? {
4750 return Ok(Fixed::Declined);
4751 }
4752 crate::pr_head::ensure_owner(state)?;
4753 let remote = state.config.merge.remote.clone();
4754 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4755 let push = if state.pr_head.is_some() {
4756 let expected = state
4757 .pr_pushed_head
4758 .as_deref()
4759 .context("missing public head lease")?;
4760 git::push_as(
4761 &winner.worktree,
4762 &remote,
4763 &winner.branch,
4764 &public_head,
4765 expected,
4766 )
4767 .await?
4768 } else {
4769 git::push(&winner.worktree, &remote, &winner.branch).await?
4770 };
4771 if !push.ok() {
4772 return Ok(Fixed::Failed(format!(
4773 "pushing {} to {remote} failed: {}",
4774 public_head, push.stderr
4775 )));
4776 }
4777 state.event(
4778 "land",
4779 format!("round {round}: pushed a fix to {public_head}"),
4780 );
4781 state.pr_pushed_head = Some(after.clone());
4782 state.save()?;
4783 Ok(Fixed::Committed { head: after })
4784}
4785
4786pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4788 crate::fixer::seat_for(state, key, agent)
4789}
4790
4791pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4794 let seen = observe(&state.repo, url).await?;
4795 let winner = state.winner().context("no winning branch to repair")?;
4796 if seen.pr.state != PrLifecycle::Open || seen.head != head {
4797 anyhow::bail!(
4798 "PR is closed or its head changed; review the current head before requesting repair again"
4799 );
4800 }
4801 if git::rev_parse(&winner.worktree, "HEAD").await? != head
4802 || !git::is_clean(&winner.worktree).await?
4803 {
4804 anyhow::bail!(
4805 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4806 );
4807 }
4808 let view = gh(
4811 &state.repo,
4812 &[
4813 "pr".into(),
4814 "view".into(),
4815 url.into(),
4816 "--json".into(),
4817 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4818 ],
4819 )
4820 .await?;
4821 if !view.0 {
4822 bail!("cannot check auto-merge: {}", view.1);
4823 }
4824 let live: serde_json::Value = serde_json::from_str(&view.1)?;
4825 let public = state.pr_branch().unwrap_or(&winner.branch);
4826 if live["state"] != "OPEN"
4827 || live["headRefOid"] != head
4828 || live["headRefName"] != public
4829 || live["baseRefName"] != state.base_branch
4830 || live["isCrossRepository"] != false
4831 {
4832 bail!("PR changed during repair preflight; review its current head explicitly");
4833 }
4834 let auto_merge = live
4835 .get("autoMergeRequest")
4836 .context("cannot read the PR auto-merge reservation; repair held")?;
4837 if !auto_merge.is_null() {
4838 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4839 if !out.0 {
4840 bail!("cannot disable auto-merge; repair held: {}", out.1);
4841 }
4842 }
4843 Ok(seen.pr)
4844}
4845
4846fn fix_prompt(
4848 state: &RunState,
4849 pr: &PrState,
4850 round: usize,
4851 budget: usize,
4852 reason: &str,
4853 logs: &str,
4854) -> String {
4855 let mut s = format!(
4856 "Your patch is open as a pull request and it is not landing. Land round \
4857 {round} of {budget}.\n\n\
4858 Pull request: {}\n\n\
4859 What is holding it: {reason}\n\n\
4860 # The task\n\n{}\n",
4861 pr.url, state.instruction
4862 );
4863
4864 if pr.failing.is_empty() {
4865 s.push_str("\n# Failing checks\n\n(none)\n");
4866 } else {
4867 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4868 if logs.trim().is_empty() {
4869 s.push_str(
4870 "\nNo log could be read; investigate the failure with necessary focused checks.\n",
4871 );
4872 } else {
4873 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4874 }
4875 }
4876
4877 if pr.review_comments.is_empty() {
4878 s.push_str("\n# Review comments\n\n(none)\n");
4879 } else {
4880 s.push_str("\n# Review comments\n");
4881 for c in &pr.review_comments {
4882 let where_ = match (&c.path, c.line) {
4883 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4884 (Some(p), None) => format!(" ({p})"),
4885 _ => String::new(),
4886 };
4887 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4888 }
4889 }
4890
4891 s.push_str(
4892 "\n# Rules\n\n\
4893 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4894 failing test; do not silence a lint with an allow attribute; do not \
4895 stretch a timeout to hide a race. If the check is right, the code is \
4896 wrong.\n\
4897 2. Change nothing the checks and the comments did not raise. A \
4898 drive-by refactor turns a one-line fix into a pull request that \
4899 needs reviewing again.\n\
4900 3. If a comment is wrong, say so with a checkable argument and change \
4901 nothing for it. A declined comment with a reason is a correct \
4902 outcome; a change made to appease a reviewer is not.\n\
4903 4. Commit in this worktree. magi pushes to the pull request's branch \
4904 for you; do not push, merge, or close anything yourself.\n\
4905 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4906 # Output\n\n\
4907 Say what you changed and why, and what you declined and why.",
4908 );
4909
4910 let language = &state.config.graph.language;
4911 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4912 let _ = write!(s, "\n\nWrite all prose in {language}.");
4913 }
4914 s.push_str(&crate::prompt::github_rule(
4916 language,
4917 &state.config.graph.github_language,
4918 ));
4919 if let Some(overlay) = state.config.prompts.overlay("fix") {
4920 let _ = write!(s, "\n\n{overlay}");
4921 }
4922 s.push('\n');
4923 s.push_str(crate::prompt::child_run_ownership());
4924 s
4925}
4926
4927async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4930 let mut out = String::new();
4931 for (name, url) in failing.iter().take(MAX_LOGS) {
4932 let args = match (job_of(url), run_of(url)) {
4933 (Some(job), _) => vec![
4934 "run".to_owned(),
4935 "view".to_owned(),
4936 "--log-failed".to_owned(),
4937 "--job".to_owned(),
4938 job,
4939 ],
4940 (None, Some(run)) => vec![
4941 "run".to_owned(),
4942 "view".to_owned(),
4943 run,
4944 "--log-failed".to_owned(),
4945 ],
4946 (None, None) => continue,
4948 };
4949 let (ok, body) = match gh(repo, &args).await {
4950 Ok(v) => v,
4951 Err(e) => (false, format!("{e:#}")),
4952 };
4953 if !ok && body.trim().is_empty() {
4954 continue;
4955 }
4956 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4957 }
4958 out
4959}
4960
4961fn job_of(details_url: &str) -> Option<String> {
4964 let after = details_url.split("/job/").nth(1)?;
4965 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4966 (!id.is_empty()).then_some(id)
4967}
4968
4969pub(crate) fn run_of(details_url: &str) -> Option<String> {
4971 let after = details_url.split("/actions/runs/").nth(1)?;
4972 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4973 (!id.is_empty()).then_some(id)
4974}
4975
4976fn stop_comment(run_id: &str, why: &str) -> String {
4980 format!(
4981 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4982 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4983 )
4984}
4985
4986async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4991 let body = stop_comment(&state.id, why);
4992 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4993 if !body.contains(MARKER) {
4994 body = format!("{MARKER}\n{body}");
4995 }
4996 let posted = gh(
4997 repo,
4998 &[
4999 "pr".to_owned(),
5000 "comment".to_owned(),
5001 pr.number.to_string(),
5002 "--body".to_owned(),
5003 body,
5004 ],
5005 )
5006 .await;
5007 match posted {
5008 Ok((true, _)) => {}
5009 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5010 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5011 }
5012 state.status = RunStatus::Blocked;
5013 state.merge = Some(MergeOutcome {
5014 mode: MergeMode::Pr,
5015 ok: false,
5016 detail: why.to_owned(),
5017 empty: false,
5018 });
5019 state.event("land", format!("stopped: {why}"));
5020 state.save()?;
5021 Ok(())
5022}
5023
5024pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5040 let out = tokio::process::Command::new("gh")
5041 .args(args)
5042 .current_dir(cwd)
5043 .env_remove("GH_REPO")
5044 .quiet()
5045 .stdin(std::process::Stdio::null())
5046 .output()
5047 .await
5048 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5049 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5050 let err = String::from_utf8_lossy(&out.stderr);
5051 if body.trim().is_empty() {
5052 body = err.into_owned();
5053 } else if !err.trim().is_empty() {
5054 body.push_str(&err);
5055 }
5056 Ok((out.status.success(), body.trim().to_owned()))
5057}
5058
5059#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5061pub(crate) enum Verdict {
5062 Pass,
5063 Fail,
5064 Pending,
5065 Unknown,
5066}
5067
5068impl Verdict {
5069 pub(crate) fn label(self) -> &'static str {
5071 match self {
5072 Self::Pass => "passed",
5073 Self::Fail => "failed",
5074 Self::Pending => "pending",
5075 Self::Unknown => "unknown",
5076 }
5077 }
5078}
5079
5080#[derive(Debug, Deserialize)]
5081#[serde(rename_all = "camelCase")]
5082struct GhPr {
5083 #[serde(default)]
5084 url: String,
5085 #[serde(default)]
5086 number: u64,
5087 #[serde(default)]
5088 state: String,
5089 #[serde(default)]
5090 title: String,
5091 #[serde(default)]
5092 status_check_rollup: Vec<GhCheck>,
5093 #[serde(default)]
5100 merge_state_status: String,
5101 #[serde(default)]
5105 head_ref_oid: String,
5106 #[serde(default)]
5107 base_ref_name: String,
5108 #[serde(default)]
5109 reviews: Vec<GhReview>,
5110 #[serde(default)]
5111 comments: Vec<GhComment>,
5112}
5113
5114#[derive(Debug, Deserialize)]
5119#[serde(rename_all = "camelCase")]
5120struct GhCheck {
5121 #[serde(default)]
5122 name: Option<String>,
5123 #[serde(default)]
5124 context: Option<String>,
5125 #[serde(default)]
5126 status: Option<String>,
5127 #[serde(default)]
5128 conclusion: Option<String>,
5129 #[serde(default)]
5130 state: Option<String>,
5131 #[serde(default)]
5132 details_url: Option<String>,
5133 #[serde(default)]
5134 target_url: Option<String>,
5135 #[serde(default)]
5138 is_required: Option<bool>,
5139}
5140
5141impl GhCheck {
5142 fn label(&self) -> String {
5144 self.name
5145 .clone()
5146 .or_else(|| self.context.clone())
5147 .unwrap_or_else(|| "(unnamed check)".to_owned())
5148 }
5149
5150 fn url(&self) -> Option<&str> {
5152 self.details_url
5153 .as_deref()
5154 .or(self.target_url.as_deref())
5155 .filter(|u| !u.is_empty())
5156 }
5157
5158 fn verdict(&self) -> Verdict {
5166 if let Some(status) = self.status.as_deref() {
5167 if !status.eq_ignore_ascii_case("COMPLETED") {
5168 return Verdict::Pending;
5169 }
5170 }
5171 let outcome = self
5172 .conclusion
5173 .as_deref()
5174 .or(self.state.as_deref())
5175 .unwrap_or("");
5176 match outcome.to_ascii_uppercase().as_str() {
5177 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5178 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5179 | "ACTION_REQUIRED" => Verdict::Fail,
5180 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5181 Verdict::Pending
5182 }
5183 _ => Verdict::Unknown,
5184 }
5185 }
5186}
5187
5188#[derive(Debug, Deserialize)]
5189struct GhAuthor {
5190 #[serde(default)]
5191 login: String,
5192}
5193
5194#[derive(Debug, Deserialize)]
5195struct GhReview {
5196 #[serde(default)]
5197 author: GhAuthor,
5198 #[serde(default)]
5199 body: String,
5200}
5201
5202#[derive(Debug, Deserialize)]
5203struct GhComment {
5204 #[serde(default)]
5205 author: GhAuthor,
5206 #[serde(default)]
5207 body: String,
5208}
5209
5210#[derive(Debug, Deserialize)]
5211struct GhUser {
5212 #[serde(default)]
5213 login: String,
5214}
5215
5216#[derive(Debug, Deserialize)]
5217struct GhInline {
5218 #[serde(default)]
5219 user: GhUser,
5220 #[serde(default)]
5221 path: Option<String>,
5222 #[serde(default)]
5223 line: Option<u64>,
5224 #[serde(default)]
5225 body: String,
5226}
5227
5228impl Default for GhAuthor {
5229 fn default() -> Self {
5230 Self {
5231 login: "(unknown)".to_owned(),
5232 }
5233 }
5234}
5235
5236impl Default for GhUser {
5237 fn default() -> Self {
5238 Self {
5239 login: "(unknown)".to_owned(),
5240 }
5241 }
5242}
5243
5244#[cfg(test)]
5245pub(crate) mod tests {
5246 use super::*;
5247 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5248
5249 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5250 format!(
5251 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5252 )
5253 }
5254
5255 #[test]
5256 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5257 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5258 assert_eq!(
5259 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5260 Ok(())
5261 );
5262 for (json, why) in [
5263 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5264 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5265 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5266 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5267 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5268 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5269 ] {
5270 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5271 .unwrap_err()
5272 .why;
5273 assert!(err.contains(why), "{json}: {err}");
5274 }
5275 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5277 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5278 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5279 assert!(
5280 !closable(
5281 &head_json("x", "main", "OPEN", false),
5282 "magi/27b2/A",
5283 "main",
5284 &[]
5285 )
5286 .unwrap_err()
5287 .retry
5288 );
5289 assert!(is_forge_url("https://github.com/o/r.git"));
5290 assert!(is_forge_url("git@github.com:o/r.git"));
5291 assert!(!is_forge_url("/tmp/origin.git"));
5292 assert!(!is_forge_url("C:\\work\\origin.git"));
5293 assert!(!is_forge_url("file:///tmp/origin.git"));
5294 assert!(forge_unavailable(
5295 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5296 ));
5297 assert!(!forge_unavailable(
5298 "gh pr list failed: error connecting to api.github.com"
5299 ));
5300 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5301 assert!(
5303 closable(
5304 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5305 "b",
5306 "main",
5307 &["aaa".to_owned()]
5308 )
5309 .is_err()
5310 );
5311 }
5312
5313 #[test]
5314 fn the_close_comment_names_the_commit_on_the_base() {
5315 let e = crate::already::Evidence {
5316 proof: crate::already::Proof::PatchId,
5317 tip: "1234567890".to_owned(),
5318 commits: vec!["0e368de0000".to_owned()],
5319 };
5320 let c = superseded_comment("main", &e);
5321 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5322 }
5323
5324 const GREEN_OPEN: &str = r####"{
5326 "url": "https://github.com/yukimemi/magi/pull/10",
5327 "number": 10,
5328 "state": "OPEN",
5329 "mergeStateStatus": "CLEAN",
5330 "statusCheckRollup": [
5331 {
5332 "__typename": "CheckRun",
5333 "conclusion": "SKIPPED",
5334 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5335 "name": "review",
5336 "status": "COMPLETED",
5337 "workflowName": "claude-review"
5338 },
5339 {
5340 "__typename": "CheckRun",
5341 "conclusion": "SUCCESS",
5342 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5343 "name": "check (ubuntu-latest)",
5344 "status": "COMPLETED",
5345 "workflowName": "CI"
5346 },
5347 {
5348 "__typename": "CheckRun",
5349 "conclusion": "SUCCESS",
5350 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5351 "name": "rustfmt",
5352 "status": "COMPLETED",
5353 "workflowName": "CI"
5354 },
5355 {
5356 "__typename": "StatusContext",
5357 "context": "CodeRabbit",
5358 "state": "SUCCESS",
5359 "targetUrl": ""
5360 }
5361 ],
5362 "reviews": [],
5363 "comments": [
5364 {
5365 "author": {
5366 "login": "coderabbitai"
5367 },
5368 "authorAssociation": "NONE",
5369 "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"
5370 }
5371 ]
5372}"####;
5373
5374 const RED_OPEN: &str = r####"{
5376 "url": "https://github.com/yukimemi/magi/pull/9",
5377 "number": 9,
5378 "state": "OPEN",
5379 "mergeStateStatus": "UNSTABLE",
5380 "statusCheckRollup": [
5381 {
5382 "__typename": "CheckRun",
5383 "conclusion": "SUCCESS",
5384 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5385 "name": "check (ubuntu-latest)",
5386 "status": "COMPLETED",
5387 "workflowName": "CI"
5388 },
5389 {
5390 "__typename": "CheckRun",
5391 "conclusion": "SUCCESS",
5392 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5393 "name": "rustfmt",
5394 "status": "COMPLETED",
5395 "workflowName": "CI"
5396 },
5397 {
5398 "__typename": "CheckRun",
5399 "conclusion": "FAILURE",
5400 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5401 "name": "editorconfig",
5402 "status": "COMPLETED",
5403 "workflowName": "CI"
5404 },
5405 {
5406 "__typename": "StatusContext",
5407 "context": "CodeRabbit",
5408 "state": "SUCCESS",
5409 "targetUrl": ""
5410 }
5411 ],
5412 "reviews": [],
5413 "comments": [
5414 {
5415 "author": {
5416 "login": "coderabbitai"
5417 },
5418 "authorAssociation": "NONE",
5419 "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"
5420 }
5421 ]
5422}"####;
5423
5424 const PENDING_OPEN: &str = r####"{
5426 "url": "https://github.com/yukimemi/magi/pull/9",
5427 "number": 9,
5428 "state": "OPEN",
5429 "statusCheckRollup": [
5430 {
5431 "__typename": "CheckRun",
5432 "conclusion": "SUCCESS",
5433 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5434 "name": "check (ubuntu-latest)",
5435 "status": "COMPLETED",
5436 "workflowName": "CI"
5437 },
5438 {
5439 "__typename": "CheckRun",
5440 "conclusion": "SUCCESS",
5441 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5442 "name": "rustfmt",
5443 "status": "COMPLETED",
5444 "workflowName": "CI"
5445 },
5446 {
5447 "__typename": "CheckRun",
5448 "conclusion": null,
5449 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5450 "name": "editorconfig",
5451 "status": "IN_PROGRESS",
5452 "workflowName": "CI"
5453 },
5454 {
5455 "__typename": "StatusContext",
5456 "context": "CodeRabbit",
5457 "state": "SUCCESS",
5458 "targetUrl": ""
5459 }
5460 ],
5461 "reviews": [],
5462 "comments": []
5463}"####;
5464
5465 const MERGED: &str = r####"{
5467 "url": "https://github.com/yukimemi/magi/pull/16",
5468 "number": 16,
5469 "state": "MERGED",
5470 "statusCheckRollup": [
5471 {
5472 "__typename": "CheckRun",
5473 "conclusion": "SUCCESS",
5474 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5475 "name": "check (ubuntu-latest)",
5476 "status": "COMPLETED",
5477 "workflowName": "CI"
5478 },
5479 {
5480 "__typename": "CheckRun",
5481 "conclusion": "SUCCESS",
5482 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5483 "name": "review",
5484 "status": "COMPLETED",
5485 "workflowName": "claude-review"
5486 }
5487 ],
5488 "reviews": [],
5489 "comments": []
5490}"####;
5491
5492 const REVIEWED_OPEN: &str = r####"{
5494 "url": "https://github.com/yukimemi/magi/pull/12",
5495 "number": 12,
5496 "state": "OPEN",
5497 "statusCheckRollup": [
5498 {
5499 "__typename": "CheckRun",
5500 "conclusion": "SUCCESS",
5501 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5502 "name": "check (ubuntu-latest)",
5503 "status": "COMPLETED",
5504 "workflowName": "CI"
5505 },
5506 {
5507 "__typename": "CheckRun",
5508 "conclusion": "SUCCESS",
5509 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5510 "name": "review",
5511 "status": "COMPLETED",
5512 "workflowName": "claude-review"
5513 }
5514 ],
5515 "reviews": [
5516 {
5517 "author": {
5518 "login": "claude"
5519 },
5520 "state": "COMMENTED",
5521 "body": ""
5522 }
5523 ],
5524 "comments": [
5525 {
5526 "author": {
5527 "login": "coderabbitai"
5528 },
5529 "authorAssociation": "NONE",
5530 "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"
5531 },
5532 {
5533 "author": {
5534 "login": "claude"
5535 },
5536 "authorAssociation": "NONE",
5537 "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"
5538 }
5539 ]
5540}"####;
5541
5542 const INLINE: &str = r####"[
5544 {
5545 "user": {
5546 "login": "claude[bot]"
5547 },
5548 "path": "src/graph.rs",
5549 "line": 231,
5550 "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"
5551 }
5552]"####;
5553
5554 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5556<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5557
5558> [!IMPORTANT]
5559> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5560>
5561> This repository does not receive automatic reviews because it has fewer than 10 stars.
5562>
5563> <details>
5564> <summary>⚙️ Run configuration</summary>
5565>
5566> **Configuration used**: defaults
5567>
5568> **Review profile**: CHILL
5569>
5570> **Plan**: Team
5571>
5572> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5573>
5574> </details>
5575
5576<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5577
5578<!-- tips_start -->
5579
5580---
5581
5582Thanks 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.
5583
5584<details>
5585<summary>❤️ Share</summary>
5586
5587- [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"####;
5588
5589 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5591
5592---
5593### Reviewing PR #16
5594
5595- [x] Read AGENTS.md conventions
5596- [x] Review `src/daemon.rs` changes
5597- [x] Review `src/main.rs` changes (new `doctor` reporting)
5598- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5599- [x] Check test coverage for new behavior
5600- [x] Run verification commands (blocked — see note)
5601- [x] Post findings"####;
5602
5603 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5605
5606---
5607### Review: `magi review <branch>` — cheap-half-only graph
5608
5609Read 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.
5610
5611**Correctness**
5612
5613- 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"####;
5614
5615 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5616 PrState {
5617 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5618 number: 16,
5619 state: PrLifecycle::Open,
5620 checks,
5621 blocking: if matches!(checks, Checks::Red) {
5625 Blocking::Yes
5626 } else {
5627 Blocking::No
5628 },
5629 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5630 review_comments: (0..comments)
5631 .map(|i| ReviewComment {
5632 author: "coderabbitai".to_owned(),
5633 path: Some("src/graph.rs".to_owned()),
5634 line: Some(231),
5635 body: format!("finding {i}"),
5636 })
5637 .collect(),
5638 }
5639 }
5640
5641 #[test]
5642 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5643 use CiExpectation::{Absent, Expected};
5644 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5645 let p = pr(checks, &["x"], 0);
5646 for waited in [Duration::ZERO, CHECKS_GRACE] {
5647 assert_eq!(
5648 decide_with(&p, 0, 4, waited, Expected),
5649 decide(&p, 0, 4, waited)
5650 );
5651 }
5652 }
5653 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5655 let p = pr(checks, &["x"], 0);
5656 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5657 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5658 }
5659 let mut p = pr(Checks::Unknown, &[], 0);
5661 p.blocking = Blocking::Conflict;
5662 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5663 p.state = PrLifecycle::Merged;
5665 assert_eq!(
5666 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5667 Step::Done { merged: true }
5668 );
5669 }
5670
5671 #[test]
5672 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5673 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5674 assert_eq!(state.number, 10);
5675 assert_eq!(state.state, PrLifecycle::Open);
5676 assert_eq!(state.checks, Checks::Green);
5677 assert!(state.failing.is_empty());
5678 assert!(
5679 state.review_comments.is_empty(),
5680 "the only comment is CodeRabbit's trigger notice: {:?}",
5681 state.review_comments
5682 );
5683 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5684 }
5685
5686 #[test]
5687 fn a_failing_check_parses_as_red_and_is_named() {
5688 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5689 assert_eq!(state.checks, Checks::Red);
5690 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5691 let mut blocking = state.clone();
5698 blocking.blocking = Blocking::Yes;
5699 match decide(&blocking, 0, 4, Duration::ZERO) {
5700 Step::Fix { reason } => {
5701 assert!(reason.contains("editorconfig"), "reason: {reason}");
5702 assert!(reason.contains("failing"), "reason: {reason}");
5703 }
5704 other => panic!("expected a fix round, got {other:?}"),
5705 }
5706 }
5707
5708 #[test]
5709 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5710 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5711 assert_eq!(state.checks, Checks::Pending);
5712 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5713 }
5714
5715 #[test]
5716 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5717 let state = parse_pr(MERGED).expect("merged fixture parses");
5718 assert_eq!(state.state, PrLifecycle::Merged);
5719 assert_eq!(
5720 decide(&state, 0, 4, Duration::ZERO),
5721 Step::Done { merged: true }
5722 );
5723 }
5724
5725 #[test]
5726 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5727 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5728 assert_eq!(state.checks, Checks::Green);
5729 let authors: Vec<&str> = state
5730 .review_comments
5731 .iter()
5732 .map(|c| c.author.as_str())
5733 .collect();
5734 assert_eq!(
5735 authors,
5736 vec!["claude"],
5737 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5738 );
5739 match decide(&state, 0, 4, Duration::ZERO) {
5740 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5741 other => panic!("expected a fix round, got {other:?}"),
5742 }
5743 }
5744
5745 #[test]
5746 fn inline_review_comments_keep_their_file_and_line() {
5747 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5748 assert_eq!(comments.len(), 1);
5749 assert_eq!(comments[0].author, "claude[bot]");
5750 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5751 assert_eq!(comments[0].line, Some(231));
5752 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5753 }
5754
5755 #[test]
5756 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5757 assert!(
5758 is_noise(CODERABBIT_TRIGGER),
5759 "CodeRabbit's trigger notice declares itself not a review"
5760 );
5761 assert!(
5762 is_noise(CLAUDE_CHECKLIST),
5763 "a progress checklist asks for nothing"
5764 );
5765 assert!(
5766 !is_noise(CLAUDE_FINDING),
5767 "a review that names a bug is input, not noise"
5768 );
5769
5770 let mut clean = pr(Checks::Green, &[], 0);
5771 clean.review_comments.push(ReviewComment {
5772 author: "coderabbitai".to_owned(),
5773 path: None,
5774 line: None,
5775 body: CODERABBIT_TRIGGER.to_owned(),
5776 });
5777 clean.review_comments.retain(|c| !is_noise(&c.body));
5778 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5779
5780 let mut found = pr(Checks::Green, &[], 0);
5781 found.review_comments.push(ReviewComment {
5782 author: "claude".to_owned(),
5783 path: None,
5784 line: None,
5785 body: CLAUDE_FINDING.to_owned(),
5786 });
5787 found.review_comments.retain(|c| !is_noise(&c.body));
5788 assert!(matches!(
5789 decide(&found, 0, 4, Duration::ZERO),
5790 Step::Fix { .. }
5791 ));
5792 }
5793
5794 #[test]
5795 fn the_policy_table_holds_for_every_combination_that_matters() {
5796 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5797 (
5798 "pending checks are waited for, even on the last round",
5799 pr(Checks::Pending, &[], 0),
5800 4,
5801 4,
5802 Duration::ZERO,
5803 Step::Wait,
5804 ),
5805 (
5806 "red checks are fixed",
5807 pr(Checks::Red, &["editorconfig"], 0),
5808 0,
5809 4,
5810 Duration::ZERO,
5811 Step::Fix {
5812 reason: "1 check(s) failing: editorconfig".to_owned(),
5813 },
5814 ),
5815 (
5816 "green with comments is fixed, not merged",
5817 pr(Checks::Green, &[], 2),
5818 1,
5819 4,
5820 Duration::ZERO,
5821 Step::Fix {
5822 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5823 .to_owned(),
5824 },
5825 ),
5826 (
5827 "green and clean merges",
5828 pr(Checks::Green, &[], 0),
5829 3,
5830 4,
5831 Duration::ZERO,
5832 Step::Merge,
5833 ),
5834 (
5835 "an unreadable rollup is waited on while the grace lasts",
5836 pr(Checks::Unknown, &[], 0),
5837 0,
5838 4,
5839 Duration::ZERO,
5840 Step::Wait,
5841 ),
5842 (
5843 "an unreadable rollup is never merged once the grace is spent",
5844 pr(Checks::Unknown, &[], 0),
5845 0,
5846 4,
5847 CHECKS_GRACE,
5848 Step::GiveUp {
5849 reason: "no check status is readable on the pull request after 3 minute(s); \
5850 refusing to merge on a guess"
5851 .to_owned(),
5852 },
5853 ),
5854 ];
5855 for (what, state, round, budget, waited, want) in cases {
5856 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5857 }
5858 }
5859
5860 #[test]
5861 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5862 let green = parse_pr(GREEN_OPEN).expect("parse");
5866 assert_eq!(green.blocking, Blocking::No);
5867 let red = parse_pr(RED_OPEN).expect("parse");
5868 assert_eq!(
5869 red.blocking,
5870 Blocking::No,
5871 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5872 );
5873 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5874 let quiet =
5876 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5877 assert_eq!(quiet.blocking, Blocking::Unsaid);
5878 }
5879
5880 #[test]
5881 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5882 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5888 nonblocking.blocking = Blocking::No;
5889 assert_eq!(
5890 decide(&nonblocking, 0, 4, Duration::ZERO),
5891 Step::Merge,
5892 "the forge says nothing is in the way, so nothing is"
5893 );
5894
5895 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5897 blocking.blocking = Blocking::Yes;
5898 assert!(matches!(
5899 decide(&blocking, 0, 4, Duration::ZERO),
5900 Step::Fix { .. }
5901 ));
5902
5903 let mut commented = pr(Checks::Red, &["coverage"], 1);
5906 commented.blocking = Blocking::No;
5907 assert!(matches!(
5908 decide(&commented, 0, 4, Duration::ZERO),
5909 Step::Fix { .. }
5910 ));
5911
5912 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5914 unsaid.blocking = Blocking::Unsaid;
5915 assert!(matches!(
5916 decide(&unsaid, 0, 4, Duration::ZERO),
5917 Step::Fix { .. }
5918 ));
5919 }
5920
5921 #[test]
5922 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5923 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5924 red.blocking = Blocking::No;
5925 assert_eq!(
5926 decide(&red, 0, 4, Duration::ZERO),
5927 Step::Merge,
5928 "announcing must not change the decision"
5929 );
5930 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5931 assert!(said.contains("yukimemi/magi"), "{said}");
5932 assert!(said.contains("#16"), "{said}");
5933 assert!(
5934 said.contains("https://github.com/yukimemi/magi/pull/16"),
5935 "{said}"
5936 );
5937 assert!(
5938 said.contains("test (windows-latest)") && said.contains("coverage"),
5939 "{said}"
5940 );
5941
5942 let green = pr(Checks::Green, &["stale"], 0);
5944 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5945 }
5946
5947 #[test]
5948 fn red_merge_notices_omit_empty_check_details() {
5949 for checks in [vec![], vec!["", " "]] {
5950 let red = pr(Checks::Red, &checks, 0);
5951 let summary = red_merge_summary("owner/repo", &red).unwrap();
5952 let notice = crate::notices::merged_red("run-1", &summary);
5953 assert_eq!(
5954 notice.message,
5955 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
5956 );
5957 assert_eq!(notice.severity, crate::notices::Severity::Warn);
5958 }
5959 let red = pr(Checks::Red, &["", " test ", " "], 0);
5960 let summary = red_merge_summary("owner/repo", &red).unwrap();
5961 assert!(summary.contains("with red checks: test ("), "{summary}");
5962 }
5963
5964 #[test]
5965 fn the_repo_label_comes_from_the_pull_request_url() {
5966 let p = Path::new("/tmp/checkout");
5967 assert_eq!(
5968 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5969 "yukimemi/magi"
5970 );
5971 assert_eq!(repo_label(p, "not a url"), "checkout");
5972 }
5973
5974 #[test]
5975 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5976 let mut conflicted = pr(Checks::Green, &[], 0);
5981 conflicted.blocking = Blocking::Conflict;
5982 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5983
5984 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5988 red.blocking = Blocking::Conflict;
5989 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5990
5991 let mut merged = pr(Checks::Red, &[], 0);
5993 merged.blocking = Blocking::Conflict;
5994 merged.state = PrLifecycle::Merged;
5995 assert_eq!(
5996 decide(&merged, 0, 4, Duration::ZERO),
5997 Step::Done { merged: true }
5998 );
5999 }
6000
6001 #[test]
6002 fn the_forge_verdict_is_read_off_merge_state_status() {
6003 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6006 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6007 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6008 }
6009 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6010 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6011 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6012 for quiet in ["", "UNKNOWN"] {
6015 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6016 assert!(Blocking::of(quiet).stops_a_merge());
6017 }
6018 }
6019
6020 #[test]
6021 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6022 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6023 let jj = "could not determine current branch: failed to run git: not on any branch";
6025
6026 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6027 .expect("the forge says merged, so it merged");
6028 assert!(landed.ok);
6029 assert!(
6030 landed.detail.contains("but the pull request is merged"),
6031 "the record must not read as a clean success: {}",
6032 landed.detail
6033 );
6034 assert!(
6035 landed.detail.contains("not on any branch"),
6036 "and it must keep what the command actually said: {}",
6037 landed.detail
6038 );
6039
6040 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6042 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6043 assert!(merged_after_all(&argv, jj, None).is_none());
6045 }
6046
6047 #[test]
6048 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6049 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6050 state.state = PrLifecycle::Closed;
6051 assert_eq!(
6052 decide(&state, 0, 4, Duration::ZERO),
6053 Step::Done { merged: false },
6054 "a human closing the pull request ends the loop, whatever CI says"
6055 );
6056 }
6057
6058 #[test]
6059 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6060 let red = decide(
6061 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6062 4,
6063 4,
6064 Duration::ZERO,
6065 );
6066 match red {
6067 Step::GiveUp { reason } => {
6068 assert!(reason.contains("editorconfig"), "reason: {reason}");
6069 assert!(reason.contains("test (macos)"), "reason: {reason}");
6070 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6071 }
6072 other => panic!("expected a give-up, got {other:?}"),
6073 }
6074
6075 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6076 match commented {
6077 Step::GiveUp { reason } => {
6078 assert!(reason.contains("unresolved"), "reason: {reason}");
6079 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6080 }
6081 other => panic!("expected a give-up, got {other:?}"),
6082 }
6083 }
6084
6085 #[test]
6086 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6087 let candidate_commit = "magi: candidate A (uncommitted work)";
6088 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6089 let argv = merge_argv(16, &subject);
6090
6091 assert!(argv.contains(&"--squash".to_owned()));
6092 assert!(argv.contains(&"--delete-branch".to_owned()));
6093 assert!(argv.contains(&"--subject".to_owned()));
6094 assert_eq!(
6095 argv.last().map(String::as_str),
6096 Some("add retries to the uploader"),
6097 "the subject must not be the candidate commit message"
6098 );
6099 assert_ne!(subject, candidate_commit);
6100 }
6101
6102 #[test]
6103 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6104 assert_eq!(
6105 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6106 "feat: a queue, an unattended loop, and a phone UI"
6107 );
6108 assert_eq!(
6109 merge_subject("", "# port the retry logic\n\ndetails"),
6110 "port the retry logic",
6111 "an empty title falls back to the task's first line, heading marks stripped"
6112 );
6113 }
6114
6115 #[test]
6116 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6117 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6118 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6119 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6120 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6121 assert_eq!(run_of(""), None);
6122 }
6123
6124 #[test]
6125 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6126 let mut out = Vec::new();
6127 push_if_outstanding(
6128 &mut out,
6129 ReviewComment {
6130 author: "yukimemi".to_owned(),
6131 path: None,
6132 line: None,
6133 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6134 },
6135 );
6136 assert!(out.is_empty());
6137 }
6138
6139 fn run_state() -> RunState {
6143 let mut state = RunState::new(
6144 std::path::PathBuf::from("/repo/magi"),
6145 "main".to_owned(),
6146 "abcdef1234".to_owned(),
6147 "add retries to the uploader".to_owned(),
6148 crate::config::Config::default(),
6149 );
6150 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6155 let nanos = std::time::SystemTime::now()
6156 .duration_since(std::time::UNIX_EPOCH)
6157 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6158 state.id = format!(
6159 "20261004-{nanos:06}-{:04x}",
6160 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6161 );
6162 state
6163 }
6164
6165 fn green_pr() -> PrState {
6166 PrState {
6167 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6168 number: 42,
6169 state: PrLifecycle::Open,
6170 checks: Checks::Green,
6171 blocking: Blocking::No,
6173 failing: Vec::new(),
6174 review_comments: vec![ReviewComment {
6175 author: "coderabbitai".to_owned(),
6176 path: Some("src/land.rs".to_owned()),
6177 line: Some(212),
6178 body: "this branch never checks the exit code".to_owned(),
6179 }],
6180 }
6181 }
6182
6183 #[test]
6184 fn land_fixer_defers_review_and_verification_without_a_cache() {
6185 let state = run_state();
6186 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6187 assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6188 assert!(p.contains("Commit in this worktree"), "{p}");
6189 }
6190
6191 #[test]
6192 fn github_facing_land_text_is_english_whatever_the_language() {
6193 let mut state = run_state();
6194 state.config.graph.language = "ja".to_owned();
6195 let comment = stop_comment(&state.id, "checks are still red");
6196 assert!(crate::github_text::check("", &comment).is_empty());
6197 assert!(comment.is_ascii(), "{comment}");
6198 assert!(comment.starts_with(MARKER));
6199
6200 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6201 let ja_at = p.find("Write all prose in ja").unwrap();
6202 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6203 assert!(ja_at < rule_at, "{p}");
6204 assert!(p.contains("stays in Japanese"), "{p}");
6205
6206 state.config.graph.language = "en".to_owned();
6207 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6208 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6209 assert!(!p.contains("does not apply"), "{p}");
6210 }
6211
6212 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6213
6214 fn panel() -> String {
6215 approval_panel(
6216 &run_state(),
6217 &green_pr(),
6218 NUMSTAT,
6219 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6220 &[
6221 "land: ask before merging".to_owned(),
6222 "land: colour the diff".to_owned(),
6223 ],
6224 "feat: merge approval from the phone",
6225 )
6226 }
6227
6228 #[test]
6229 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6230 let html = panel();
6231 for needle in [
6232 "42",
6233 "main",
6234 "src/land.rs",
6235 "src/web.rs",
6236 "assets/logo.png",
6237 "feat: merge approval from the phone",
6238 "land: ask before merging",
6239 "land: colour the diff",
6240 "coderabbitai",
6241 "this branch never checks the exit code",
6242 "green",
6243 ] {
6244 assert!(html.contains(needle), "the panel must state `{needle}`");
6245 }
6246 }
6247
6248 fn winning_candidate(summary: &str) -> Candidate {
6251 Candidate {
6252 index: 0,
6253 label: 'A',
6254 agent: "opus".to_owned(),
6255 branch: "magi/x/A".to_owned(),
6256 worktree: PathBuf::from("/wt/A"),
6257 summary: summary.to_owned(),
6258 stat: String::new(),
6259 files: 1,
6260 commits: 1,
6261 empty: false,
6262 failed: None,
6263 verified_noop: None,
6264 duration_ms: 0,
6265 folded: false,
6266 }
6267 }
6268
6269 fn uncontested_tally() -> Tally {
6270 Tally {
6271 first_choice: BTreeMap::from([('A', 1)]),
6272 borda: BTreeMap::new(),
6273 winner: 'A',
6274 rankings: 1,
6275 unanimous_initial: true,
6276 deliberated: false,
6277 changed_votes: 0,
6278 unanimous_final: true,
6279 tie_break: None,
6280 judges: 1,
6281 present: 1,
6282 quorum: 1,
6283 met_quorum: true,
6284 uncontested: None,
6285 }
6286 }
6287
6288 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6289 ReviewRecord {
6290 attempts: 0,
6291 reviewer,
6292 agent: agent.to_owned(),
6293 summary: summary.to_owned(),
6294 findings: Vec::new(),
6295 vote: None,
6296 failed: None,
6297 duration_ms: 0,
6298 }
6299 }
6300
6301 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6302 let answered = reviews.len();
6303 ReviewRound {
6304 round,
6305 head: "abc1234".to_owned(),
6306 verified_head: None,
6307 verified_at: None,
6308 reviews,
6309 e2e: Vec::new(),
6310 verify_retried: false,
6311 e2e_deferred: false,
6312 e2e_defer_reason: None,
6313 fix: None,
6314 blocking: 0,
6315 answered,
6316 expected: answered,
6317 clean: true,
6318 progressed: false,
6319 vote_split: false,
6320 reconsideration: Vec::new(),
6321 verdict: None,
6322 }
6323 }
6324
6325 #[test]
6326 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6327 let en = panel();
6328 assert!(en.contains("Task"), "{en}");
6329 assert!(en.contains("add retries to the uploader"), "{en}");
6330
6331 let mut state = run_state();
6332 state.config.graph.language = "ja".to_owned();
6333 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6334 assert!(ja.contains("タスク"), "{ja}");
6335 assert!(
6336 ja.contains("add retries to the uploader"),
6337 "the task itself is not translated: {ja}"
6338 );
6339 }
6340
6341 #[test]
6342 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6343 let html = panel();
6347 assert!(!html.contains("What changed"), "{html}");
6348 assert!(!html.contains("Review verdict"), "{html}");
6349 }
6350
6351 #[test]
6352 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6353 let mut state = run_state();
6354 state.candidates = vec![winning_candidate("")];
6355 state.tally = Some(uncontested_tally());
6356 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6357 assert!(
6358 !html.contains("What changed"),
6359 "an empty summary must not render an empty box: {html}"
6360 );
6361 }
6362
6363 #[test]
6364 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6365 let mut state = run_state();
6366 state.candidates = vec![winning_candidate(
6367 "Added a retry loop around the uploader PUT call.",
6368 )];
6369 state.tally = Some(uncontested_tally());
6370 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6371 assert!(en.contains("What changed"), "{en}");
6372 assert!(
6373 en.contains("Added a retry loop around the uploader PUT call."),
6374 "{en}"
6375 );
6376
6377 state.config.graph.language = "ja".to_owned();
6378 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6379 assert!(ja.contains("変更内容"), "{ja}");
6380 assert!(
6381 ja.contains("Added a retry loop around the uploader PUT call."),
6382 "{ja}"
6383 );
6384 }
6385
6386 #[test]
6387 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6388 let mut state = run_state();
6389 state.reviews = vec![
6390 review_round(
6391 1,
6392 vec![review_record(1, "alpha", "found a race, sent back")],
6393 ),
6394 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6395 ];
6396 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6397 assert!(en.contains("Review verdict"), "{en}");
6398 assert!(en.contains("race is fixed, clean"), "{en}");
6399 assert!(
6400 !en.contains("found a race, sent back"),
6401 "only the round that actually cleared the merge should show: {en}"
6402 );
6403
6404 state.config.graph.language = "ja".to_owned();
6405 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6406 assert!(ja.contains("レビューの結論"), "{ja}");
6407 assert!(ja.contains("レビュアー"), "{ja}");
6408 assert!(ja.contains("race is fixed, clean"), "{ja}");
6409 }
6410
6411 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6417 ReviewRecord {
6418 attempts: 0,
6419 reviewer,
6420 agent: agent.to_owned(),
6421 summary: String::new(),
6422 findings: Vec::new(),
6423 vote: None,
6424 failed: Some(reason.to_owned()),
6425 duration_ms: 0,
6426 }
6427 }
6428
6429 #[test]
6430 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6431 let mut state = run_state();
6432 state.reviews = vec![review_round(
6433 1,
6434 vec![
6435 review_record(1, "alpha", "clean, nothing to add"),
6436 unanswered_review_record(2, "beta", "timed out"),
6437 ],
6438 )];
6439 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6440 assert!(en.contains("clean, nothing to add"), "{en}");
6441 assert!(
6442 en.contains("produced no answer: timed out"),
6443 "a seat that never answered must say so, not render a blank box: {en}"
6444 );
6445 assert!(
6446 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6447 "no reviewer box may be left empty: {en}"
6448 );
6449
6450 state.config.graph.language = "ja".to_owned();
6451 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6452 assert!(ja.contains("回答なし: timed out"), "{ja}");
6453 }
6454
6455 #[test]
6456 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6457 let html = panel();
6458 assert!(!html.contains("<script"), "no script survives the csp");
6459 assert!(!html.contains("<form"), "form-action is 'none'");
6460 let pr = green_pr();
6461 assert_eq!(
6462 html.matches("http").count(),
6463 html.matches(pr.url.as_str()).count(),
6464 "the only http url in the panel is the pull request's own link"
6465 );
6466 assert!(
6467 html.contains("target=\"_blank\""),
6468 "the PR link must open in a new tab so it escapes the iframe"
6469 );
6470 assert!(
6471 html.contains("rel=\"noopener noreferrer\""),
6472 "external links opening in new tabs must carry noopener noreferrer"
6473 );
6474 }
6475
6476 #[test]
6477 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6478 let html = panel();
6479 assert!(
6480 html.contains(">+</span>"),
6481 "an added line carries a `+` in the gutter, not only a background"
6482 );
6483 assert!(
6484 html.contains(">-</span>"),
6485 "a removed line carries a `-` in the gutter, not only a background"
6486 );
6487 assert!(
6488 html.contains(">new line</span>"),
6489 "the marker is moved to the gutter, so the body is printed once without it"
6490 );
6491 }
6492
6493 #[test]
6494 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6495 let total = DIFF_MAX_LINES + 100;
6496 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6497 let html = approval_panel(
6498 &run_state(),
6499 &green_pr(),
6500 NUMSTAT,
6501 &diff,
6502 &[],
6503 "feat: something long",
6504 );
6505 assert!(
6506 html.contains(&format!("100 of {total} diff lines omitted")),
6507 "the note must say exactly how much was cut"
6508 );
6509 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6510 assert!(
6511 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6512 "nothing past the threshold is rendered"
6513 );
6514 assert!(
6515 html.contains("/repo/magi"),
6516 "the note says where the rest is"
6517 );
6518 }
6519
6520 #[test]
6521 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6522 let html = approval_panel(
6523 &run_state(),
6524 &green_pr(),
6525 "1\t2\tsrc/<b>&\"x\"'.rs",
6526 "",
6527 &[],
6528 "subject",
6529 );
6530 assert!(html.contains("src/<b>&"x"'.rs"));
6531 assert!(
6532 !html.contains("<b>"),
6533 "an agent-influenced path must never become markup"
6534 );
6535 }
6536
6537 #[tokio::test]
6538 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6539 let a = std::path::PathBuf::from("/repo/a");
6540 let b = std::path::PathBuf::from("/repo/b");
6541
6542 let held = repo_merge_lock(&a).lock_owned().await;
6543
6544 assert!(
6547 repo_merge_lock(&a).try_lock().is_err(),
6548 "a second merge into the same repository must not proceed concurrently"
6549 );
6550
6551 assert!(
6555 repo_merge_lock(&b).try_lock().is_ok(),
6556 "a different repository's merge lock must be independent"
6557 );
6558
6559 drop(held);
6560 assert!(
6561 repo_merge_lock(&a).try_lock().is_ok(),
6562 "the lock is released once the holder is done"
6563 );
6564 }
6565
6566 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6567 let mut state = landing_state();
6568 let mut rec = review_record(1, "alpha", "precise rejection reason");
6569 rec.vote = Some(if reject {
6570 crate::verdict::ReviewVote::Reject
6571 } else {
6572 crate::verdict::ReviewVote::Approve
6573 });
6574 if major {
6575 rec.findings = contested().findings;
6576 }
6577 let mut round = review_round(1, vec![rec]);
6578 round.clean = false;
6579 round.blocking = usize::from(major);
6580 state.reviews.push(round);
6581 state.config.graph.review_rounds = 1;
6582 state
6583 }
6584
6585 #[test]
6586 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6587 for (major, reject) in [(true, false), (false, true), (true, true)] {
6588 let state = repair_state(major, reject);
6589 assert!(repair_available(&state));
6590 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
6591 }
6592 assert!(!repair_available(&repair_state(false, false)));
6593 let mut clean = repair_state(true, false);
6594 clean.reviews[0].clean = true;
6595 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
6596 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6597 }
6598
6599 #[tokio::test]
6600 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6601 let mut state = repair_state(true, false);
6602 let pr = green_pr();
6603 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6604 .await
6605 .unwrap();
6606 let store = ask::Questions::open();
6607 let id = state.land_approval.as_ref().unwrap().question.clone();
6608 store
6610 .update(&id, |q| {
6611 q.choices = vec![APPROVE.into(), HOLD.into()];
6612 Ok(())
6613 })
6614 .unwrap();
6615 let upgraded = store.get(&id).unwrap();
6616 assert_eq!(upgraded.choices, approval_choices(&state));
6617 assert!(!upgraded.actions.contains_key(HOLD));
6618 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6619 assert!(
6620 store
6621 .list()
6622 .iter()
6623 .any(|q| q.id == id && q.choices == upgraded.choices)
6624 );
6625 assert!(
6626 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6627 );
6628 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6629 store
6630 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6631 .unwrap();
6632 store
6635 .update(&id, |q| {
6636 q.actions.clear();
6637 Ok(())
6638 })
6639 .unwrap();
6640 assert_eq!(
6641 store.get(&id).unwrap().chosen_action(),
6642 Some(&ask::ChoiceAction::Resume {
6643 run: state.id.clone()
6644 })
6645 );
6646 let binding = state.land_approval.clone();
6647 assert_eq!(
6648 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6649 .await
6650 .unwrap(),
6651 ApprovalGate::Repair
6652 );
6653 assert!(state.land_approval.is_none());
6654 assert_eq!(state.merge_repairs.len(), 1);
6655 let request = &state.merge_repairs[0];
6656 assert_eq!(request.question, id);
6657 assert_eq!(request.head, "abc");
6658 assert_eq!(request.pr, pr.url);
6659 assert_eq!(
6660 request.reviews[0].reviews[0].summary,
6661 "precise rejection reason"
6662 );
6663 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6664 assert_eq!(
6665 request.reviews[0].reviews[0].findings[0].detail,
6666 state.reviews[0].reviews[0].findings[0].detail
6667 );
6668 state.land_approval = binding;
6670 assert_eq!(
6671 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6672 .await
6673 .unwrap(),
6674 ApprovalGate::Repair
6675 );
6676 assert_eq!(state.merge_repairs.len(), 1);
6677 }
6678
6679 #[tokio::test]
6680 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6681 for choice in [REPAIR, HOLD] {
6682 let mut state = repair_state(false, true);
6683 let observed = seen("abc", Checks::Green, "CLEAN", false);
6684 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6685 .await
6686 .unwrap();
6687 let id = state.land_approval.as_ref().unwrap().question.clone();
6688 ask::Questions::open()
6689 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6690 .unwrap();
6691 let forge = Scripted::new(vec![observed], vec![(true, "")]);
6692 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6693 .await
6694 .unwrap();
6695 assert!(
6696 forge
6697 .argvs()
6698 .iter()
6699 .all(|a| a.iter().any(|s| s == "--disable-auto"))
6700 );
6701 assert!(!forge.calls().contains(&"fix"));
6702 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6703 assert_ne!(state.status, RunStatus::Merged);
6704 }
6705 }
6706
6707 #[test]
6708 fn only_the_merge_choice_merges_and_silence_holds() {
6709 let table = [
6710 (None, Approval::Hold),
6711 (Some("merge"), Approval::Merge),
6712 (Some(" merge\n"), Approval::Merge),
6713 (Some("hold"), Approval::Hold),
6714 (Some(""), Approval::Hold),
6715 (Some("yes"), Approval::Hold),
6716 ];
6717 for (answer, want) in table {
6718 assert_eq!(
6719 approval(answer),
6720 want,
6721 "answer {answer:?} must resolve to {want:?}"
6722 );
6723 }
6724 }
6725
6726 #[tokio::test]
6727 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6728 let mut state = landing_state();
6729 state.config.graph.land_approval = true;
6730 let pr = green_pr();
6731
6732 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6733 .await
6734 .unwrap();
6735 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6736 assert!(
6737 !state.parked,
6738 "approval_gate itself never sets `parked`; only its caller does"
6739 );
6740
6741 let store = ask::Questions::open();
6742 let filed: Vec<_> = store
6743 .list()
6744 .into_iter()
6745 .filter(|q| q.run == state.id)
6746 .collect();
6747 assert_eq!(filed.len(), 1, "exactly one question is filed");
6748 assert_eq!(filed[0].node, APPROVAL_NODE);
6749 assert_eq!(filed[0].choices, approval_choices(&state));
6750 assert!(filed[0].status.open());
6751
6752 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6756 .await
6757 .unwrap();
6758 assert_eq!(again, ApprovalGate::Pending);
6759 let still_one = store
6760 .list()
6761 .into_iter()
6762 .filter(|q| q.run == state.id)
6763 .count();
6764 assert_eq!(
6765 still_one, 1,
6766 "asking twice must not double-file the question"
6767 );
6768 }
6769
6770 #[tokio::test]
6771 async fn approving_the_existing_question_is_read_back_as_approved() {
6772 crate::run::pin_test_home();
6773 let mut state = run_state();
6774 state.config.graph.land_approval = true;
6775 let pr = green_pr();
6776 assert_eq!(
6777 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6778 .await
6779 .unwrap(),
6780 ApprovalGate::Pending
6781 );
6782
6783 let store = ask::Questions::open();
6784 let mut q = store
6785 .list()
6786 .into_iter()
6787 .find(|q| q.run == state.id)
6788 .expect("filed above");
6789 let talks = crate::talk::Talks::open();
6790 let cfg = crate::config::Config {
6791 agents: vec![crate::config::AgentSpec {
6792 id: "mock".into(),
6793 kind: crate::config::AgentKind::Command,
6794 model: None,
6795 command: vec!["true".into()],
6796 extra_args: Vec::new(),
6797 env: Default::default(),
6798 prompt_delivery: None,
6799 }],
6800 ..crate::config::Config::default()
6801 };
6802 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6803 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6804 crate::talk::drain(&mut talk, &talks).unwrap();
6805 assert_eq!(
6806 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6807 .await
6808 .unwrap(),
6809 ApprovalGate::Pending,
6810 "consultation cannot approve or hold the merge"
6811 );
6812 q = store.get(&q.id).unwrap();
6813 assert!(q.answer.is_none());
6814 talk.turns.push(crate::talk::Turn {
6815 sender: None,
6816 breaks: Some(Vec::new()),
6817 messages: Vec::new(),
6818 posted_by: None,
6819 who: crate::talk::Who::Operator,
6820 body: "merge abc".into(),
6821 at: Timestamp::now(),
6822 attachments: Vec::new(),
6823 usage: None,
6824 });
6825 talks.put(&mut talk).unwrap();
6826 crate::consult::validate_answer(
6827 &q,
6828 &talks,
6829 &talk.id,
6830 crate::queue::CHAT_NODE,
6831 APPROVE,
6832 Some("merge abc"),
6833 )
6834 .unwrap();
6835 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6836 store.put(&mut q).unwrap();
6837
6838 assert_eq!(
6839 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6840 .await
6841 .unwrap(),
6842 ApprovalGate::Approved
6843 );
6844 }
6845
6846 #[tokio::test]
6847 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
6848 crate::run::pin_test_home();
6849 let store = ask::Questions::open();
6850 for via_list in [false, true] {
6851 let mut state = repair_state(false, true);
6852 let pr = green_pr();
6853 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6854 .await
6855 .unwrap();
6856 let id = state.land_approval.as_ref().unwrap().question.clone();
6857 store
6858 .update(&id, |q| {
6859 q.actions.insert(
6860 HOLD.to_owned(),
6861 ask::ChoiceAction::Resume {
6862 run: state.id.clone(),
6863 },
6864 );
6865 Ok(())
6866 })
6867 .unwrap();
6868 let q = if via_list {
6869 store.list().into_iter().find(|q| q.id == id).unwrap()
6870 } else {
6871 store.get(&id).unwrap()
6872 };
6873 assert!(!q.actions.contains_key(HOLD));
6874 store
6875 .update(&id, |q| {
6876 assert!(!q.actions.contains_key(HOLD));
6878 assert!(!upgrade_repair_choice(q, &state));
6879 q.answer(ask::Answer::Choice(HOLD.to_owned()))
6880 })
6881 .unwrap();
6882 assert!(store.get(&id).unwrap().chosen_action().is_none());
6883 assert_eq!(
6884 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6885 .await
6886 .unwrap(),
6887 ApprovalGate::Held
6888 );
6889 }
6890 }
6891
6892 #[tokio::test]
6893 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6894 crate::run::pin_test_home();
6895 let store = ask::Questions::open();
6896
6897 let mut held_state = run_state();
6898 held_state.config.graph.land_approval = true;
6899 let pr = green_pr();
6900 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6901 .await
6902 .unwrap();
6903 let mut q = store
6904 .list()
6905 .into_iter()
6906 .find(|q| q.run == held_state.id)
6907 .expect("filed above");
6908 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6909 store.put(&mut q).unwrap();
6910 assert_eq!(
6911 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6912 .await
6913 .unwrap(),
6914 ApprovalGate::Held
6915 );
6916
6917 let mut abandoned_state = run_state();
6918 abandoned_state.config.graph.land_approval = true;
6919 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6920 .await
6921 .unwrap();
6922 let mut q = store
6923 .list()
6924 .into_iter()
6925 .find(|q| q.run == abandoned_state.id)
6926 .expect("filed above");
6927 q.abandon("no answer within the timeout");
6928 store.put(&mut q).unwrap();
6929 assert_eq!(
6930 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6931 .await
6932 .unwrap(),
6933 ApprovalGate::Held,
6934 "silence must never merge"
6935 );
6936 }
6937
6938 fn contested() -> ContestedHandoff {
6939 let finding = |id: &str, n: u32| crate::verdict::Finding {
6940 id: id.to_owned(),
6941 severity: crate::verdict::Severity::Major,
6942 file: Some("src/a.rs".to_owned()),
6943 line: Some(n),
6944 title: format!("problem {id}"),
6945 detail: String::new(),
6946 };
6947 ContestedHandoff {
6948 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6949 rejecters: vec![(1, "alpha".to_owned())],
6950 }
6951 }
6952
6953 #[test]
6954 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6955 let mut state = run_state();
6956 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6957 state.contested_handoff = Some(contested());
6958 assert!(contested_to_ask(&state).is_some());
6959 state.config.graph.hold_contested_merge = false;
6960 assert!(
6961 contested_to_ask(&state).is_none(),
6962 "the switch restores today"
6963 );
6964 }
6965
6966 #[test]
6967 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6968 let q = ask::Question::new(
6969 "run-1".to_owned(),
6970 APPROVAL_NODE.to_owned(),
6971 "land".to_owned(),
6972 "Merge?".to_owned(),
6973 String::new(),
6974 vec![APPROVE.to_owned(), HOLD.to_owned()],
6975 );
6976 let none = deputy_brief(&q, None);
6977 assert!(none.contains("could not be read"), "{none}");
6978 assert!(none.contains("Silence is a hold"), "{none}");
6979
6980 let mut state = run_state();
6981 state.pr = Some(crate::run::PrRecord {
6982 url: "https://example.test/pull/7".to_owned(),
6983 number: 7,
6984 state: "open".to_owned(),
6985 checks: "green".to_owned(),
6986 round: 0,
6987 rounds: 3,
6988 red_at_merge: Vec::new(),
6989 });
6990 state.contested_handoff = Some(contested());
6991 let b = deputy_brief(&q, Some(&state));
6992 assert!(b.contains("https://example.test/pull/7"), "{b}");
6993 assert!(b.contains("[R3-1-1] major (src/a.rs:1)"), "{b}");
6994 assert!(b.contains("#1"), "the rejecting seat: {b}");
6995 state.contested_handoff = None;
6996 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6997 }
6998
6999 #[test]
7000 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
7001 for lang in ["en", "ja"] {
7002 let mut cfg = crate::config::Config::default();
7003 cfg.graph.language = lang.to_owned();
7004 let w = words(&cfg.graph.language);
7005 let text = w.approval_detail(
7006 "https://github.com/yukimemi/magi/pull/42",
7007 "main",
7008 "feat: x",
7009 Some(&contested()),
7010 );
7011 assert!(text.contains("pull/42"), "{text}");
7012 assert!(
7013 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
7014 "{text}"
7015 );
7016 assert!(text.contains("R3-1-5"), "{text}");
7017 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
7018 assert!(text.contains("2"), "the rest are counted: {text}");
7019 assert!(text.contains("#1 (alpha)"), "{text}");
7020 }
7021 let plain = words("en").approval_detail("u", "main", "s", None);
7022 assert!(!plain.contains("reject"), "{plain}");
7023 }
7024
7025 #[tokio::test]
7026 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
7027 crate::run::pin_test_home();
7028 let mut state = run_state();
7029 state.config.graph.land_approval = false;
7030 state.contested_handoff = Some(contested());
7031 let pr = green_pr();
7032 let c = contested_to_ask(&state);
7033 assert_eq!(
7034 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7035 .await
7036 .unwrap(),
7037 ApprovalGate::Pending,
7038 "silence is a hold"
7039 );
7040 let store = ask::Questions::open();
7041 let filed: Vec<_> = store
7042 .list()
7043 .into_iter()
7044 .filter(|q| q.run == state.id)
7045 .collect();
7046 assert_eq!(filed.len(), 1);
7047 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7048
7049 assert_eq!(
7050 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7051 .await
7052 .unwrap(),
7053 ApprovalGate::Pending
7054 );
7055 let mut q = store
7056 .list()
7057 .into_iter()
7058 .find(|q| q.run == state.id)
7059 .unwrap();
7060 assert_eq!(q.id, filed[0].id, "the same question after a resume");
7061 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7062 store.put(&mut q).unwrap();
7063 assert_eq!(
7064 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7065 .await
7066 .unwrap(),
7067 ApprovalGate::Approved
7068 );
7069 }
7070
7071 #[test]
7072 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7073 let rows = parse_numstat(NUMSTAT);
7074 assert_eq!(
7075 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7076 ["src/web.rs", "src/land.rs", "assets/logo.png"]
7077 );
7078 assert_eq!(rows[2].added, None, "a binary file has no line counts");
7079 }
7080 #[test]
7081 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7082 let mut state = run_state();
7086 state.config.graph.language = "ja".to_owned();
7087 let pr = green_pr();
7088 let commits = ["c1".to_owned()];
7089
7090 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7091 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7092 assert!(ja.contains("squash されるコミット"), "{ja}");
7093 assert!(ja.contains("レビューコメント"), "{ja}");
7094 assert!(ja.contains("差分"), "{ja}");
7095 assert!(
7096 !ja.contains("Commits being squashed"),
7097 "no English left over"
7098 );
7099
7100 let w = words("ja");
7101 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7102 assert!(
7103 w.approval_detail("http://x/1", "main", "feat: x", None)
7104 .contains("パネル")
7105 );
7106
7107 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7109 assert!(ja.contains("feat: x"), "nor is the merge subject");
7110
7111 state.config.graph.language = "en".to_owned();
7114 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7115 assert!(en.contains("Commits being squashed"), "{en}");
7116 assert_eq!(words("Klingon").html_lang, "en");
7117 }
7118
7119 #[test]
7123 fn pick_open_pr_classifies_by_count_and_base() {
7124 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7125 assert_eq!(
7126 pick_open_pr(one, "main").unwrap(),
7127 OpenPr::One {
7128 url: "https://x/pull/58".into(),
7129 title: "t".into()
7130 }
7131 );
7132 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7133 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7134 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7135 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7136 assert_eq!(
7137 pick_open_pr(two, "main").unwrap(),
7138 OpenPr::Many(vec!["u1".into(), "u2".into()])
7139 );
7140 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7142 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7143 assert!(pick_open_pr("not json", "main").is_err());
7144 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7146 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7147 }
7148
7149 #[test]
7150 fn pick_merged_pr_picks_the_unique_match() {
7151 let json = r#"[
7152 {"url": "https://github.com/o/r/pull/42", "number": 42,
7153 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7154 ]"#;
7155 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7156 let found = pick_merged_pr(json, "main", created_at)
7157 .expect("valid json")
7158 .expect("one unambiguous match");
7159 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7160 assert_eq!(found.number, 42);
7161 }
7162
7163 #[test]
7167 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7168 let json = r#"[
7169 {"url": "https://github.com/o/r/pull/42", "number": 42,
7170 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7171 {"url": "https://github.com/o/r/pull/43", "number": 43,
7172 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7173 ]"#;
7174 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7175 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7176 }
7177
7178 #[test]
7182 fn pick_merged_pr_ignores_a_different_base_branch() {
7183 let json = r#"[
7184 {"url": "https://github.com/o/r/pull/42", "number": 42,
7185 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
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]
7194 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7195 let json = r#"[
7196 {"url": "https://github.com/o/r/pull/42", "number": 42,
7197 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7198 ]"#;
7199 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7200 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7201 }
7202
7203 #[test]
7204 fn slug_of_pr_url_reads_host_owner_and_repo() {
7205 assert_eq!(
7206 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7207 Some("github.com/yukimemi/shun")
7208 );
7209 }
7210
7211 #[test]
7212 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7213 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7214 assert_eq!(slug_of_pr_url("not a url at all"), None);
7215 assert_eq!(slug_of_pr_url("https://github.com"), None);
7216 }
7217
7218 #[test]
7219 fn slug_of_repo_url_reads_host_owner_and_repo() {
7220 assert_eq!(
7221 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7222 Some("github.com/yukimemi/magi")
7223 );
7224 assert_eq!(slug_of_repo_url("https://github.com"), None);
7225 }
7226
7227 #[test]
7228 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7229 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7230 .expect("same repo, different case");
7231 }
7232
7233 #[test]
7238 fn ensure_same_repo_refuses_a_different_repo() {
7239 let err =
7240 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7241 let msg = format!("{err:#}");
7242 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7243 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7244 }
7245
7246 #[test]
7251 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7252 let err = ensure_same_repo(
7253 "github.com/yukimemi/magi",
7254 "github.example.com/yukimemi/magi",
7255 )
7256 .unwrap_err();
7257 let msg = format!("{err:#}");
7258 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7259 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7260 }
7261
7262 #[tokio::test]
7267 async fn find_external_merge_returns_none_without_a_winner() {
7268 let state = RunState::new(
7269 PathBuf::from("/no/such/repo"),
7270 "main".to_owned(),
7271 "0000000000000000000000000000000000000000".to_owned(),
7272 "irrelevant".to_owned(),
7273 crate::config::Config::default(),
7274 );
7275 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7276 }
7277
7278 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7279 let mut run = RunState::new(
7280 PathBuf::from(repo),
7281 "main".to_owned(),
7282 "abcdef1234".to_owned(),
7283 "x".to_owned(),
7284 crate::config::Config::default(),
7285 );
7286 run.id = id.to_owned();
7287 run.status = status;
7288 run.driver_exited = true;
7289 run.pr = Some(crate::run::PrRecord {
7290 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7291 url: url.to_owned(),
7292 state: state.to_owned(),
7293 checks: "red".to_owned(),
7294 round: 0,
7295 rounds: 2,
7296 red_at_merge: Vec::new(),
7297 });
7298 run.save_under(home).unwrap();
7299 }
7300
7301 fn recorded(home: &Path, id: &str) -> String {
7302 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7303 serde_json::from_str::<RunState>(&body)
7304 .unwrap()
7305 .pr
7306 .unwrap()
7307 .state
7308 }
7309
7310 const PR: &str = "https://github.com/o/r/pull/7";
7311
7312 #[test]
7313 fn write_through_updates_predecessors_and_siblings_only() {
7314 let tmp = tempfile::tempdir().unwrap();
7315 let h = tmp.path();
7316 pr_run(
7317 h,
7318 "20261004-100000-aaaa",
7319 "/repo/r",
7320 RunStatus::Superseded,
7321 PR,
7322 "open",
7323 );
7324 pr_run(
7325 h,
7326 "20261004-100100-bbbb",
7327 "/repo/r",
7328 RunStatus::Blocked,
7329 PR,
7330 "open",
7331 );
7332 pr_run(
7334 h,
7335 "20261004-100200-cccc",
7336 "/repo/r",
7337 RunStatus::Landing,
7338 PR,
7339 "open",
7340 );
7341 pr_run(
7343 h,
7344 "20261004-100300-dddd",
7345 "/repo/other",
7346 RunStatus::Blocked,
7347 "https://github.com/o/other/pull/7",
7348 "open",
7349 );
7350 pr_run(
7352 h,
7353 "20261004-100400-eeee",
7354 "/repo/r",
7355 RunStatus::Blocked,
7356 "https://github.com/o/r/pull/8",
7357 "open",
7358 );
7359 pr_run(
7360 h,
7361 "20261004-100500-ffff",
7362 "/repo/r",
7363 RunStatus::Merged,
7364 PR,
7365 "open",
7366 );
7367 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7368
7369 assert_eq!(
7370 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7371 2
7372 );
7373 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7374 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7375 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7376 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7377 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7378 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7380 assert_eq!(
7382 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7383 0
7384 );
7385 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7386 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7387 }
7388
7389 #[test]
7390 fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
7391 let home = tempfile::tempdir().unwrap();
7392 let live_id = "20261004-100000-aaaa";
7393 let unknown_id = "20261004-100100-bbbb";
7394 for id in [live_id, unknown_id] {
7395 pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
7396 let mut s = RunState::load_under(id, home.path()).unwrap();
7397 s.driver_exited = false;
7398 if id == live_id {
7399 s.driver_pid = Some(std::process::id());
7400 s.driver_started_at = crate::proc::process_started_at(std::process::id());
7401 }
7402 s.save_under(home.path()).unwrap();
7403 }
7404 let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
7405 assert_eq!(apply_pr_states(home.path(), &known), 0);
7406 for id in [live_id, unknown_id] {
7407 assert_eq!(recorded(home.path(), id), "open");
7408 }
7409 }
7410
7411 #[test]
7412 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7413 let tmp = tempfile::tempdir().unwrap();
7414 let h = tmp.path();
7415 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7416 pr_run(
7417 h,
7418 "20261004-100000-aaaa",
7419 "/repo/r",
7420 RunStatus::Superseded,
7421 &url(1),
7422 "open",
7423 );
7424 pr_run(
7425 h,
7426 "20261004-100100-bbbb",
7427 "/repo/r",
7428 RunStatus::Blocked,
7429 &url(2),
7430 "open",
7431 );
7432 pr_run(
7433 h,
7434 "20261004-100200-cccc",
7435 "/repo/r",
7436 RunStatus::Ready,
7437 &url(3),
7438 "open",
7439 );
7440 pr_run(
7441 h,
7442 "20261004-100300-dddd",
7443 "/repo/r",
7444 RunStatus::Ready,
7445 &url(4),
7446 "open",
7447 );
7448 pr_run(
7449 h,
7450 "20261004-100400-eeee",
7451 "/repo/r",
7452 RunStatus::Implementing,
7453 &url(1),
7454 "open",
7455 );
7456 assert_eq!(stale_open_prs(h).len(), 4);
7457
7458 let mut known = BTreeMap::new();
7459 known.insert(url(1), PrLifecycle::Merged);
7460 known.insert(url(2), PrLifecycle::Closed);
7461 known.insert(url(3), PrLifecycle::Open);
7462 assert_eq!(apply_pr_states(h, &known), 2);
7464 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7465 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
7466 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7467 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7468 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7469 assert_eq!(apply_pr_states(h, &known), 0);
7470 }
7471
7472 use std::collections::VecDeque;
7475 use std::sync::Mutex;
7476
7477 struct Scripted {
7480 views: Mutex<VecDeque<Seen>>,
7481 merges: Mutex<VecDeque<(bool, String)>>,
7482 fix: Mutex<Option<Fixed>>,
7483 log: Mutex<Vec<&'static str>>,
7484 argvs: Mutex<Vec<Vec<String>>>,
7485 required: Mutex<Option<BTreeSet<String>>>,
7486 merged: Mutex<bool>,
7489 queued: Mutex<bool>,
7490 unreadable_after_merge: Mutex<bool>,
7492 reruns: Mutex<Vec<String>>,
7494 rerun_fails: Mutex<bool>,
7495 clock: Mutex<i64>,
7497 }
7498
7499 impl Scripted {
7500 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
7501 Self {
7502 views: Mutex::new(views.into()),
7503 merges: Mutex::new(
7504 merges
7505 .into_iter()
7506 .map(|(ok, m)| (ok, m.to_owned()))
7507 .collect(),
7508 ),
7509 fix: Mutex::new(None),
7510 log: Mutex::new(Vec::new()),
7511 argvs: Mutex::new(Vec::new()),
7512 required: Mutex::new(None),
7513 merged: Mutex::new(false),
7514 queued: Mutex::new(false),
7515 unreadable_after_merge: Mutex::new(false),
7516 reruns: Mutex::new(Vec::new()),
7517 rerun_fails: Mutex::new(false),
7518 clock: Mutex::new(1_000),
7519 }
7520 }
7521 fn argvs(&self) -> Vec<Vec<String>> {
7522 self.argvs.lock().unwrap().clone()
7523 }
7524 fn calls(&self) -> Vec<&'static str> {
7525 self.log.lock().unwrap().clone()
7526 }
7527 }
7528
7529 impl Forge for Scripted {
7530 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7531 self.log.lock().unwrap().push("view");
7532 if *self.unreadable_after_merge.lock().unwrap()
7533 && !self.argvs.lock().unwrap().is_empty()
7534 {
7535 anyhow::bail!("forge unreachable");
7536 }
7537 let mut v = self.views.lock().unwrap();
7538 let mut seen = if v.len() > 1 {
7539 v.pop_front().unwrap()
7540 } else {
7541 v[0].clone()
7542 };
7543 if *self.merged.lock().unwrap() {
7544 seen.pr.state = PrLifecycle::Merged;
7545 }
7546 Ok(seen)
7547 }
7548 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7549 self.log.lock().unwrap().push("merge");
7550 self.argvs.lock().unwrap().push(argv.to_vec());
7551 let out = self
7552 .merges
7553 .lock()
7554 .unwrap()
7555 .pop_front()
7556 .expect("unscripted merge");
7557 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7558 {
7559 *self.merged.lock().unwrap() = true;
7560 }
7561 Ok(out)
7562 }
7563 async fn poll(&self) {
7564 self.log.lock().unwrap().push("poll");
7565 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7566 }
7567 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7568 self.log.lock().unwrap().push("rerun");
7569 self.reruns.lock().unwrap().push(run.to_owned());
7570 if *self.rerun_fails.lock().unwrap() {
7571 anyhow::bail!("gh run rerun: no");
7572 }
7573 Ok(())
7574 }
7575 fn now(&self) -> i64 {
7576 *self.clock.lock().unwrap()
7577 }
7578 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7579 self.required.lock().unwrap().clone()
7580 }
7581 async fn fix(
7582 &self,
7583 _state: &mut RunState,
7584 _pr: &PrState,
7585 _round: usize,
7586 _budget: usize,
7587 _reason: &str,
7588 _logs: &str,
7589 ) -> Result<Fixed> {
7590 self.log.lock().unwrap().push("fix");
7591 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7592 }
7593 }
7594
7595 const REFUSED: &str =
7596 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7597
7598 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7599 let mut pr = green_pr();
7600 pr.checks = checks;
7601 pr.blocking = Blocking::of(merge_state);
7602 if !comments {
7603 pr.review_comments.clear();
7604 }
7605 Seen {
7606 pr,
7607 title: "feat: x".to_owned(),
7608 failing_urls: Vec::new(),
7609 head: head.to_owned(),
7610 rollup_head: head.to_owned(),
7611 merge_state: merge_state.to_owned(),
7612 contexts: Vec::new(),
7613 base: "main".to_owned(),
7614 }
7615 }
7616
7617 fn landing_state() -> RunState {
7618 crate::run::pin_test_home();
7619 let mut state = run_state();
7620 state.config.graph.land_approval = false;
7621 state
7622 }
7623
7624 #[test]
7625 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7626 assert!(!awaiting_new_head(None, "aaa"));
7627 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7628 assert!(awaiting_new_head(Some("abc123"), "def456"));
7629 assert!(awaiting_new_head(Some("abc123"), ""));
7630 }
7631
7632 #[test]
7633 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7634 let open = |c, m: &str| seen("a", c, m, false);
7635 let table = [
7636 (None, false, Refused::Pending),
7637 (
7638 Some(open(Checks::Pending, "BLOCKED")),
7639 false,
7640 Refused::Pending,
7641 ),
7642 (
7643 Some(open(Checks::Unknown, "BLOCKED")),
7644 false,
7645 Refused::Pending,
7646 ),
7647 (
7648 Some(open(Checks::Green, "UNKNOWN")),
7649 false,
7650 Refused::Pending,
7651 ),
7652 (Some(open(Checks::Green, "")), false, Refused::Pending),
7653 (
7654 Some(open(Checks::Green, "BLOCKED")),
7655 false,
7656 Refused::Recheck,
7657 ),
7658 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7659 ];
7660 for (after, rechecked, want) in table {
7661 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7662 }
7663 let mut closed = open(Checks::Green, "CLEAN");
7664 closed.pr.state = PrLifecycle::Closed;
7665 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7666 }
7667
7668 #[tokio::test]
7669 async fn a_normal_landing_merges_on_the_first_look() {
7670 let mut state = landing_state();
7671 let forge = Scripted::new(
7672 vec![seen("a", Checks::Green, "CLEAN", false)],
7673 vec![(true, "")],
7674 );
7675 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7676 .await
7677 .unwrap();
7678 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7680 assert_eq!(state.status, RunStatus::Merged);
7681 }
7682
7683 #[tokio::test]
7684 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7685 let mut state = landing_state();
7686 let forge = Scripted::new(
7687 vec![seen("a", Checks::Green, "CLEAN", false)],
7688 std::iter::repeat_n((true, ""), 100).collect(),
7689 );
7690 *forge.queued.lock().unwrap() = true;
7691 let task = async {
7692 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7693 .await
7694 .unwrap();
7695 };
7696 task.await;
7699 assert_ne!(state.status, RunStatus::Merged);
7700 }
7701
7702 #[tokio::test]
7703 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7704 let mut state = landing_state();
7705 let forge = Scripted::new(
7706 vec![seen("a", Checks::Green, "CLEAN", false)],
7707 std::iter::repeat_n((true, ""), 100).collect(),
7708 );
7709 *forge.unreadable_after_merge.lock().unwrap() = true;
7710 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7711 .await
7712 .ok();
7713 assert_ne!(state.status, RunStatus::Merged);
7714 }
7715
7716 #[tokio::test]
7717 async fn a_task_public_head_is_awaited_on_land_entry() {
7718 let mut state = landing_state();
7719 state.pr_head = Some("magi/task-abcd-1234".into());
7720 state.pr_pushed_head = Some("new".into());
7721 let forge = Scripted::new(
7722 vec![
7723 seen("old", Checks::Green, "CLEAN", false),
7724 seen("new", Checks::Green, "CLEAN", false),
7725 ],
7726 vec![(true, "")],
7727 );
7728 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7729 .await
7730 .unwrap();
7731 assert_eq!(
7732 forge.calls(),
7733 ["view", "poll", "view", "view", "merge", "view"]
7734 );
7735 assert_eq!(state.status, RunStatus::Merged);
7736 }
7737
7738 #[tokio::test]
7739 async fn a_superseded_public_head_owner_cannot_reenter_land() {
7740 let mut state = landing_state();
7741 state.pr_taken_over_by = Some("next".into());
7742 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7743 assert!(
7744 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7745 .await
7746 .is_err()
7747 );
7748 assert!(forge.calls().is_empty());
7749 }
7750
7751 #[tokio::test]
7752 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7753 let mut state = landing_state();
7754 let forge = Scripted::new(
7755 vec![
7756 seen("old", Checks::Green, "CLEAN", true),
7757 seen("old", Checks::Green, "CLEAN", true),
7759 seen("new", Checks::Pending, "BLOCKED", true),
7760 seen("new", Checks::Green, "CLEAN", true),
7761 ],
7762 vec![(true, "")],
7763 );
7764 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7765 head: "NEW".to_owned(),
7766 });
7767 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7768 .await
7769 .unwrap();
7770 assert_eq!(
7771 forge.calls(),
7772 [
7773 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7774 "view"
7775 ]
7776 );
7777 assert_eq!(state.status, RunStatus::Merged);
7778 }
7779
7780 #[tokio::test]
7781 async fn a_head_that_never_arrives_stops_naming_both_commits() {
7782 let mut state = landing_state();
7783 let forge = Scripted::new(
7784 vec![
7785 seen("old", Checks::Green, "CLEAN", true),
7786 seen("someone-elses", Checks::Green, "CLEAN", true),
7787 ],
7788 vec![],
7789 );
7790 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7791 head: "mine".to_owned(),
7792 });
7793 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7794 .await
7795 .unwrap();
7796 assert!(!forge.calls().contains(&"merge"));
7797 let why = state.merge.as_ref().unwrap().detail.clone();
7798 assert!(
7799 why.contains("mine") && why.contains("someone-elses"),
7800 "{why}"
7801 );
7802 assert_eq!(state.status, RunStatus::Blocked);
7803 }
7804
7805 #[tokio::test]
7806 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7807 let mut state = landing_state();
7808 let forge = Scripted::new(
7809 vec![
7810 seen("a", Checks::Green, "CLEAN", false),
7811 seen("a", Checks::Green, "CLEAN", false),
7812 seen("a", Checks::Pending, "BLOCKED", false),
7813 seen("a", Checks::Pending, "BLOCKED", false),
7814 seen("a", Checks::Green, "CLEAN", false),
7815 ],
7816 vec![(false, REFUSED), (true, "")],
7817 );
7818 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7819 .await
7820 .unwrap();
7821 assert_eq!(
7822 forge.calls(),
7823 [
7824 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7825 "view"
7826 ]
7827 );
7828 assert_eq!(state.status, RunStatus::Merged);
7829 }
7830
7831 #[tokio::test]
7832 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7833 let mut state = landing_state();
7834 let forge = Scripted::new(
7835 vec![
7836 seen("a", Checks::Green, "CLEAN", false),
7837 seen("a", Checks::Green, "BLOCKED", false),
7838 ],
7839 vec![(false, REFUSED), (false, REFUSED)],
7840 );
7841 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7842 .await
7843 .unwrap();
7844 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7846 let why = state.merge.as_ref().unwrap().detail.clone();
7847 assert!(
7848 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7849 "{why}"
7850 );
7851 assert_eq!(state.status, RunStatus::Blocked);
7852 }
7853
7854 #[test]
7855 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7856 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7857 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7858 assert_eq!(bound_head("old", "old", Some("new")), None);
7860 assert_eq!(bound_head("new", "old", Some("new")), None);
7862 assert_eq!(bound_head("new", "old", None), None);
7863 assert_eq!(bound_head("", "", None), None);
7865 assert_eq!(bound_head("", "", Some("new")), None);
7866 assert_eq!(bound_head("abc", "", None), None);
7867 }
7868
7869 fn view_json(head: &str) -> String {
7870 format!(
7871 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7872 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7873 "reviews":[],"comments":[]}}"#
7874 )
7875 }
7876
7877 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7878 format!(
7879 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7880 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7881 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7882 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7883 )
7884 }
7885
7886 #[test]
7887 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7888 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7891 assert_eq!(s.rollup_head, "old");
7892 assert_eq!(s.pr.checks, Checks::Red);
7893 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7894 assert_eq!(s.failing_urls.len(), 1);
7895 }
7896
7897 #[test]
7898 fn checks_come_from_the_node_not_the_view() {
7899 let view = view_json("new").replace(
7900 r#""reviews""#,
7901 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7902 );
7903 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7904 assert_eq!(s.pr.checks, Checks::Green);
7905 assert!(s.pr.failing.is_empty());
7906 assert_eq!(
7907 bound_head(&s.head, &s.rollup_head, Some("new")),
7908 Some("new")
7909 );
7910 }
7911
7912 #[test]
7913 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7914 for node in [
7915 None,
7916 Some("not json".to_owned()),
7917 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7918 Some(node_json("new", "SUCCESS", true)),
7919 ] {
7920 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7921 assert!(s.rollup_head.is_empty());
7922 assert_eq!(s.pr.checks, Checks::Unknown);
7923 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7924 }
7925 }
7926
7927 #[test]
7928 fn the_merge_command_is_pinned_to_the_observed_head() {
7929 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7930 let at = argv
7931 .iter()
7932 .position(|a| a == "--match-head-commit")
7933 .unwrap();
7934 assert_eq!(argv[at + 1], "deadbeef");
7935 }
7936
7937 #[tokio::test]
7938 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7939 let mut state = landing_state();
7940 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7943 stale.rollup_head = "old".to_owned();
7944 let forge = Scripted::new(
7945 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7946 vec![],
7947 );
7948 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7949 head: "new".to_owned(),
7950 });
7951 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7952 .await
7953 .unwrap();
7954 assert!(!forge.calls().contains(&"merge"));
7955 assert_eq!(state.status, RunStatus::Blocked);
7956 let why = state.merge.as_ref().unwrap().detail.clone();
7957 assert!(why.contains("new") && why.contains("old"), "{why}");
7958 }
7959
7960 #[test]
7961 fn a_refusal_read_against_another_commits_checks_is_pending() {
7962 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7963 after.rollup_head = "old".to_owned();
7964 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7965 }
7966
7967 #[tokio::test]
7968 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7969 let mut state = landing_state();
7970 let forge = Scripted::new(
7971 vec![seen("a", Checks::Red, "CLEAN", false)],
7972 vec![(true, "")],
7973 );
7974 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7975 .await
7976 .unwrap();
7977 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7978 assert_eq!(state.status, RunStatus::Merged);
7979 }
7980
7981 #[tokio::test]
7982 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7983 let mut state = landing_state();
7984 let forge = Scripted::new(
7985 vec![
7986 seen("a", Checks::Green, "CLEAN", false),
7987 seen("a", Checks::Green, "CLEAN", false),
7988 seen("b", Checks::Green, "BLOCKED", false),
7990 seen("b", Checks::Green, "CLEAN", false),
7991 ],
7992 vec![(false, REFUSED), (true, "")],
7993 );
7994 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7995 .await
7996 .unwrap();
7997 assert_eq!(
7998 forge.calls(),
7999 [
8000 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
8001 ]
8002 );
8003 assert_eq!(state.status, RunStatus::Merged);
8004 }
8005
8006 fn merged_view(head: &str) -> Seen {
8007 let mut m = seen(head, Checks::Green, "CLEAN", false);
8008 m.pr.state = PrLifecycle::Merged;
8009 m
8010 }
8011
8012 fn has(argv: &[String], flag: &str) -> bool {
8013 argv.iter().any(|a| a == flag)
8014 }
8015
8016 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
8017 let at = argv.iter().position(|a| a == flag)?;
8018 argv.get(at + 1).map(String::as_str)
8019 }
8020
8021 const URL: &str = "https://github.com/o/r/pull/42";
8022
8023 #[tokio::test]
8024 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
8025 let mut state = landing_state();
8026 let forge = Scripted::new(
8027 vec![
8028 seen("abc", Checks::Green, "CLEAN", false),
8029 seen("abc", Checks::Green, "CLEAN", false),
8030 ],
8031 vec![(true, "")],
8032 );
8033 land_with(&mut state, URL, &forge).await.unwrap();
8034 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
8035 let argv = &forge.argvs()[0];
8036 assert!(has(argv, "--squash") && has(argv, "--subject"));
8037 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
8038 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
8039 assert_eq!(state.status, RunStatus::Merged);
8040 assert!(state.land_armed_head.is_none());
8041 }
8042
8043 #[tokio::test]
8044 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
8045 let mut state = landing_state();
8046 state.land_armed_head = Some("a".to_owned());
8047 let forge = Scripted::new(
8048 vec![seen("a", Checks::Green, "CLEAN", false)],
8049 vec![(true, ""), (true, "")],
8050 );
8051 land_with(&mut state, URL, &forge).await.unwrap();
8052 let argvs = forge.argvs();
8053 assert!(has(&argvs[0], "--disable-auto"));
8054 assert!(!has(&argvs[1], "--auto"));
8055 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
8056 assert_eq!(state.status, RunStatus::Merged);
8057 assert!(state.land_armed_head.is_none());
8058 }
8059
8060 #[tokio::test]
8061 async fn an_approval_never_carries_over_to_a_new_head() {
8062 crate::run::pin_test_home();
8063 let mut state = run_state();
8064 state.config.graph.land_approval = true;
8065 let pr = green_pr();
8066 let store = ask::Questions::open();
8067
8068 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8069 .await
8070 .unwrap();
8071 let mut q = store
8072 .list()
8073 .into_iter()
8074 .find(|q| q.run == state.id)
8075 .unwrap();
8076 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8077 store.put(&mut q).unwrap();
8078 assert_eq!(
8079 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8080 .await
8081 .unwrap(),
8082 ApprovalGate::Approved,
8083 "the same head keeps its approval"
8084 );
8085
8086 assert_eq!(
8088 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8089 .await
8090 .unwrap(),
8091 ApprovalGate::Pending
8092 );
8093 let all: Vec<_> = store
8094 .list()
8095 .into_iter()
8096 .filter(|q| q.run == state.id)
8097 .collect();
8098 assert_eq!(all.len(), 2);
8099
8100 state.land_approval = None;
8102 assert_eq!(
8103 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8104 .await
8105 .unwrap(),
8106 ApprovalGate::Pending
8107 );
8108 let open = store
8109 .list()
8110 .into_iter()
8111 .filter(|q| q.run == state.id && q.status.open())
8112 .count();
8113 assert_eq!(open, 1, "the superseded question was retired");
8114 }
8115
8116 #[tokio::test]
8117 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8118 let mut state = landing_state();
8119 let forge = Scripted::new(
8120 vec![
8121 seen("a", Checks::Green, "CLEAN", false),
8122 seen("b", Checks::Green, "CLEAN", false),
8124 ],
8125 vec![(true, "")],
8126 );
8127 land_with(&mut state, URL, &forge).await.unwrap();
8128 let argvs = forge.argvs();
8129 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8130 assert!(!has(&argvs[0], "--auto"));
8131 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8132 assert_eq!(state.status, RunStatus::Merged);
8133 }
8134
8135 fn passing(label: &str) -> CheckInfo {
8136 CheckInfo {
8137 label: label.to_owned(),
8138 verdict: Verdict::Pass,
8139 required: Some(false),
8140 run: None,
8141 }
8142 }
8143
8144 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8145 (label.to_owned(), run.map(str::to_owned))
8146 }
8147
8148 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8149 LandReruns {
8150 head: head.to_owned(),
8151 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8152 }
8153 }
8154
8155 #[test]
8156 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8157 let f = [failing("test (macos)", Some("7"))];
8158 assert_eq!(
8159 rerun_step(&f, None, "h", 100),
8160 RerunStep::Rerun(vec!["7".to_owned()])
8161 );
8162 let rec = reran("h", &[("7", 100)]);
8163 let grace = crate::release_watch::RERUN_GRACE;
8164 assert_eq!(
8165 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8166 RerunStep::Grace
8167 );
8168 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8169 }
8170
8171 #[test]
8172 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8173 let f = [failing("a", Some("7")), failing("ci/external", None)];
8174 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8175 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8176 }
8177
8178 #[test]
8179 fn a_moved_head_forgets_the_reruns() {
8180 let f = [failing("a", Some("7"))];
8181 let rec = reran("old", &[("7", 100)]);
8182 assert_eq!(
8183 rerun_step(&f, Some(&rec), "new", 101),
8184 RerunStep::Rerun(vec!["7".to_owned()])
8185 );
8186 }
8187
8188 #[test]
8189 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8190 let f = [
8191 failing("a", Some("7")),
8192 failing("b", Some("7")),
8193 failing("c", Some("8")),
8194 ];
8195 assert_eq!(
8196 rerun_step(&f, None, "h", 0),
8197 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8198 );
8199 let rec = reran("h", &[("7", 0)]);
8200 assert_eq!(
8201 rerun_step(&f, Some(&rec), "h", 1),
8202 RerunStep::Rerun(vec!["8".to_owned()])
8203 );
8204 }
8205
8206 #[test]
8207 fn only_blocking_failures_are_candidates_for_a_rerun() {
8208 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8209 label: label.to_owned(),
8210 verdict,
8211 required,
8212 run: Some(run.to_owned()),
8213 };
8214 let got = rerunnable(&[
8215 check("passed", Verdict::Pass, Some(true), "1"),
8216 check("optional", Verdict::Fail, Some(false), "2"),
8217 check("required", Verdict::Fail, Some(true), "3"),
8218 check("unknown", Verdict::Fail, None, "4"),
8219 ]);
8220 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8221 assert_eq!(labels, ["required", "unknown"]);
8222 }
8223
8224 fn red(head: &str, run: Option<&str>) -> Seen {
8225 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8226 s.pr.failing = vec!["test (macos)".to_owned()];
8227 s.contexts = vec![CheckInfo {
8228 label: "test (macos)".to_owned(),
8229 verdict: Verdict::Fail,
8230 required: Some(true),
8231 run: run.map(str::to_owned),
8232 }];
8233 s
8234 }
8235
8236 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8237
8238 #[tokio::test]
8239 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8240 let mut state = landing_state();
8241 let forge = Scripted::new(
8242 vec![
8243 red("a", Some("7")),
8244 seen("a", Checks::Pending, "BLOCKED", false),
8245 seen("a", Checks::Green, "CLEAN", false),
8246 ],
8247 vec![(true, "")],
8248 );
8249 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8250 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8251 assert!(!forge.calls().contains(&"fix"));
8252 assert_eq!(state.status, RunStatus::Merged);
8253 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8254 let argvs = forge.argvs();
8255 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8256 assert!(
8257 state
8258 .events
8259 .iter()
8260 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8261 );
8262 }
8263
8264 #[tokio::test]
8265 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8266 let mut state = landing_state();
8267 let forge = Scripted::new(
8268 vec![
8269 red("a", Some("7")),
8270 seen("a", Checks::Green, "CLEAN", false),
8271 ],
8272 vec![(true, "")],
8273 );
8274 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8275 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8277 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8278 let calls = forge.calls();
8279 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8280 let fix_at = calls.iter().position(|c| *c == "fix");
8281 assert!(fix_at.is_none_or(|f| f > rerun_at));
8282 }
8283
8284 #[tokio::test]
8285 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8286 let mut state = landing_state();
8287 let mut v = red("a", Some("7"));
8288 v.pr.checks = Checks::Pending;
8289 v.contexts.push(CheckInfo {
8290 label: "slow".to_owned(),
8291 verdict: Verdict::Pending,
8292 required: Some(true),
8293 run: Some("8".to_owned()),
8294 });
8295 let forge = Scripted::new(
8296 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8297 vec![(true, "")],
8298 );
8299 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8300 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8301 assert_eq!(state.status, RunStatus::Merged);
8302 }
8303
8304 #[test]
8305 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8306 let mut v = red("a", Some("7"));
8307 v.contexts.push(CheckInfo {
8308 label: "same run, still going".to_owned(),
8309 verdict: Verdict::Pending,
8310 required: Some(true),
8311 run: Some("7".to_owned()),
8312 });
8313 assert!(rerunnable(&v.contexts).is_empty());
8314 }
8315
8316 #[tokio::test]
8317 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8318 let mut state = landing_state();
8319 state.config.graph.land_rounds = 0;
8320 let forge = Scripted::new(
8321 vec![
8322 red("a", Some("7")),
8323 seen("a", Checks::Green, "CLEAN", false),
8324 ],
8325 vec![(true, "")],
8326 );
8327 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8328 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8329 assert_eq!(state.status, RunStatus::Merged);
8330 assert!(!forge.calls().contains(&"fix"));
8331 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8332 }
8333
8334 #[tokio::test]
8335 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8336 let mut state = landing_state();
8337 state.config.graph.land_rounds = 1;
8338 let forge = Scripted::new(
8339 vec![
8340 seen("a", Checks::Green, "CLEAN", true),
8341 red("b", Some("7")),
8342 seen("b", Checks::Green, "CLEAN", false),
8343 ],
8344 vec![(true, "")],
8345 );
8346 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8347 head: "b".to_owned(),
8348 });
8349 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8350 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8351 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8352 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8353 assert_eq!(state.status, RunStatus::Merged);
8354 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8355 assert_eq!(
8356 value_of(&forge.argvs()[0], "--match-head-commit"),
8357 Some("b")
8358 );
8359 }
8360
8361 #[tokio::test]
8362 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8363 let mut state = landing_state();
8364 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8365 *forge.rerun_fails.lock().unwrap() = true;
8366 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8367 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8368 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8369 assert_eq!(
8370 forge.calls().iter().filter(|c| **c == "fix").count(),
8371 1,
8372 "{:?}",
8373 forge.calls()
8374 );
8375 }
8376
8377 #[tokio::test]
8378 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8379 let mut state = landing_state();
8380 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8381 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8383 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8384 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8385 assert!(forge.reruns.lock().unwrap().is_empty());
8386 }
8387
8388 #[tokio::test]
8389 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8390 let mut state = landing_state();
8391 state.config.graph.rerun_flaky_checks = false;
8392 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8393 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8394 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8395 assert!(forge.reruns.lock().unwrap().is_empty());
8396 assert!(forge.calls().contains(&"fix"));
8397
8398 let mut state = landing_state();
8399 let forge = Scripted::new(vec![red("a", None)], vec![]);
8400 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8401 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8402 assert!(forge.reruns.lock().unwrap().is_empty());
8403 assert!(forge.calls().contains(&"fix"));
8404 }
8405
8406 fn names(xs: &[&str]) -> BTreeSet<String> {
8407 xs.iter().map(|x| (*x).to_owned()).collect()
8408 }
8409
8410 #[test]
8411 fn a_required_check_the_rollup_never_listed_is_named() {
8412 let req = names(&["build"]);
8413 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8414 assert!(why.contains("never reported: build"), "{why}");
8415 assert!(!why.contains("probably waiting for a review"), "{why}");
8416 }
8417
8418 #[test]
8419 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8420 let why = waiting_on("BLOCKED", &[passing("review")], None);
8421 assert!(why.contains("could not be read"), "{why}");
8422 assert!(!why.contains("probably waiting for a review"), "{why}");
8423 }
8424
8425 #[test]
8426 fn all_required_reported_keeps_the_review_guess() {
8427 let req = names(&["build"]);
8428 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8429 assert!(why.contains("probably waiting for a review"), "{why}");
8430 assert!(!why.contains("never reported"), "{why}");
8431 }
8432
8433 #[test]
8434 fn required_names_match_the_rollup_ignoring_case_only() {
8435 let req = names(&["Build"]);
8436 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8437 assert!(!why.contains("never reported"), "{why}");
8438 }
8439
8440 #[test]
8441 fn required_contexts_are_read_from_protection_and_rulesets() {
8442 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8443 assert_eq!(
8444 parse_classic_required(classic),
8445 Some(names(&["build", "lint"]))
8446 );
8447 let rules = r#"[{"type":"pull_request","parameters":{}},
8448 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8449 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8450 assert_eq!(parse_ruleset_required("nope"), None);
8451 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8452 }
8453
8454 #[test]
8455 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8456 let shown = BTreeSet::new();
8457 let ok = seen("a", Checks::Green, "CLEAN", false);
8458 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8459 assert!(guard(Some(&ok)));
8460 assert!(!guard(None));
8461 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
8462 let mut stale = ok.clone();
8463 stale.rollup_head = "old".to_owned();
8464 assert!(!guard(Some(&stale)));
8465 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
8466 assert!(!guard(Some(&merged_view("a"))));
8467 }
8468
8469 #[test]
8470 fn the_rollup_node_carries_whether_each_check_is_required() {
8471 let node = node_json("new", "SUCCESS", false)
8472 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
8473 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
8474 assert_eq!(s.contexts.len(), 1);
8475 assert_eq!(s.contexts[0].required, Some(true));
8476 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
8477 assert_eq!(s.contexts[0].required, None);
8478 }
8479
8480 #[tokio::test]
8481 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
8482 let mut state = landing_state();
8483 state.land_armed_head = Some("a".to_owned());
8484 let forge = Scripted::new(
8485 vec![seen("a", Checks::Green, "CLEAN", true)],
8486 vec![(false, "disable exploded")],
8487 );
8488 land_with(&mut state, URL, &forge).await.unwrap();
8489 assert!(!forge.calls().contains(&"fix"));
8490 assert_eq!(state.status, RunStatus::Blocked);
8491 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
8492 let why = state.merge.as_ref().unwrap().detail.clone();
8493 assert!(why.contains("disable exploded"), "{why}");
8494 }
8495}