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 for choice in approval_choices(state) {
663 if !q.choices.contains(&choice) {
664 q.choices.push(choice);
665 }
666 }
667 if q.merge_target.is_none() {
668 q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
669 pr: pr.url.clone(),
670 head: state.land_approval.as_ref().unwrap().head.clone(),
671 language: state.config.graph.language.clone(),
672 });
673 }
674 }
675 for choice in q.choices.clone() {
676 if (q.status.open()
677 && crate::merge_decision::stops(&choice)
678 && choice != crate::merge_decision::DISMISS)
679 || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
680 {
681 q.actions.insert(
682 choice,
683 ask::ChoiceAction::Resume {
684 run: state.id.clone(),
685 },
686 );
687 }
688 }
689 before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
690}
691
692pub fn unhedged(message: &str, quote: &str) -> bool {
700 let quote = quote.trim();
701 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
702}
703
704fn hedged(text: &str) -> bool {
708 const WORDS: &[&str] = &[
709 "maybe",
710 "probably",
711 "perhaps",
712 "might",
713 "if",
714 "unless",
715 "not",
716 "no",
717 "nope",
718 "stop",
719 "dont",
720 "abort",
721 "revert",
722 "undo",
723 "never",
724 "wait",
725 "hold",
726 "cancel",
727 "but",
728 "think",
729 "guess",
730 "suppose",
731 "unsure",
732 "yet",
733 "except",
734 "only",
735 "cannot",
736 "should",
737 "once",
738 "provided",
739 "providing",
740 "when",
741 "whenever",
742 "after",
743 "until",
744 "before",
745 "assuming",
746 "given",
747 "while",
748 "whether",
749 "depending",
750 "pending",
751 ];
752 const JA: &[&str] = &[
753 "かも",
754 "たぶん",
755 "多分",
756 "なら",
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 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
787 return true;
788 }
789 text.to_lowercase()
790 .replace('\u{2019}', "'")
791 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
792 .filter(|w| !w.is_empty())
793 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
794}
795
796fn retracts(message: &str) -> bool {
799 let lower = message.to_lowercase();
800 [
801 "やっぱ",
802 "待って",
803 "撤回",
804 "never mind",
805 "actually",
806 "on second thought",
807 ]
808 .iter()
809 .any(|w| lower.contains(w))
810 || lower
811 .split(|c: char| !c.is_ascii_alphabetic())
812 .any(|w| w == "wait")
813}
814
815pub const APPROVAL_NODE: &str = "land-approval";
821
822pub const DIFF_MAX_LINES: usize = 400;
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833pub enum Approval {
834 Merge,
836 Repair,
838 Hold,
840}
841
842pub fn approval(answer: Option<&str>) -> Approval {
850 match answer {
851 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
852 Some(a) if a.trim() == REPAIR => Approval::Repair,
853 _ => Approval::Hold,
854 }
855}
856
857#[derive(Debug, Clone, Copy, PartialEq, Eq)]
859enum ApprovalGate {
860 Approved,
862 Repair,
864 Held,
867 Pending,
869}
870
871fn esc(s: &str) -> String {
881 let mut out = String::with_capacity(s.len());
882 for c in s.chars() {
883 match c {
884 '&' => out.push_str("&"),
885 '<' => out.push_str("<"),
886 '>' => out.push_str(">"),
887 '"' => out.push_str("""),
888 '\'' => out.push_str("'"),
889 _ => out.push(c),
890 }
891 }
892 out
893}
894
895#[derive(Debug, Clone, PartialEq, Eq)]
897struct StatRow {
898 path: String,
899 added: Option<u64>,
901 removed: Option<u64>,
902}
903
904impl StatRow {
905 fn churn(&self) -> u64 {
908 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
909 }
910}
911
912fn parse_numstat(numstat: &str) -> Vec<StatRow> {
918 let mut rows: Vec<StatRow> = numstat
919 .lines()
920 .filter_map(|line| {
921 let mut parts = line.splitn(3, '\t');
922 let added = parts.next()?.trim();
923 let removed = parts.next()?.trim();
924 let path = parts.next()?.trim();
925 if path.is_empty() {
926 return None;
927 }
928 Some(StatRow {
929 path: path.to_owned(),
930 added: added.parse().ok(),
931 removed: removed.parse().ok(),
932 })
933 })
934 .collect();
935 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
938 rows
939}
940
941fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
950 if line.starts_with("+++") || line.starts_with("---") {
951 (" ", "color:#57606a;font-weight:600", line)
952 } else if let Some(body) = line.strip_prefix('+') {
953 ("+", "background:#e6ffec;color:#0a3622", body)
954 } else if let Some(body) = line.strip_prefix('-') {
955 ("-", "background:#ffebe9;color:#5c1a17", body)
956 } else if line.starts_with("@@") {
957 ("~", "background:#eef2ff;color:#3730a3", line)
958 } else if let Some(body) = line.strip_prefix(' ') {
959 (" ", "", body)
960 } else {
961 (" ", "color:#57606a;font-weight:600", line)
962 }
963}
964
965struct Words {
974 html_lang: &'static str,
975 task: &'static str,
976 what_changed: &'static str,
977 review_verdict: &'static str,
978 reviewer: &'static str,
979 reviewer_no_answer: &'static str,
980 checks: &'static str,
981 nothing_failing: &'static str,
982 files_changed: &'static str,
983 commits: &'static str,
984 no_commits: &'static str,
985 comments: &'static str,
986 no_comments: &'static str,
987 diff: &'static str,
988 truncated: &'static str,
989 lands_as: &'static str,
990}
991
992const EN: Words = Words {
993 html_lang: "en",
994 task: "Task",
995 what_changed: "What changed",
996 review_verdict: "Review verdict",
997 reviewer: "Reviewer",
998 reviewer_no_answer: "produced no answer",
999 checks: "Checks",
1000 nothing_failing: "Nothing failing.",
1001 files_changed: "file(s) changed",
1002 commits: "Commits being squashed",
1003 no_commits: "No commit subjects could be read from the branch.",
1004 comments: "Review comments",
1005 no_comments: "Nothing outstanding at this observation.",
1006 diff: "Diff",
1007 truncated: "Truncated",
1008 lands_as: "They land as one commit titled",
1009};
1010
1011const JA: Words = Words {
1012 html_lang: "ja",
1013 task: "タスク",
1014 what_changed: "変更内容",
1015 review_verdict: "レビューの結論",
1016 reviewer: "レビュアー",
1017 reviewer_no_answer: "回答なし",
1018 checks: "チェック",
1019 nothing_failing: "失敗しているものはありません。",
1020 files_changed: "ファイル変更",
1021 commits: "squash されるコミット",
1022 no_commits: "ブランチからコミット件名を読めませんでした。",
1023 comments: "レビューコメント",
1024 no_comments: "この時点で未対応のものはありません。",
1025 diff: "差分",
1026 truncated: "省略",
1027 lands_as: "これらは次の件名の1コミットとして入ります:",
1028};
1029
1030impl Words {
1031 fn lands_as_tail(&self) -> &'static str {
1035 if self.html_lang == "ja" {
1036 "。この件名も承認の対象です。"
1037 } else {
1038 ", which you are approving too."
1039 }
1040 }
1041
1042 fn approval_summary(&self, number: u64, subject: &str) -> String {
1044 if self.html_lang == "ja" {
1045 format!("プルリクエスト #{number} をマージ: {subject}")
1046 } else {
1047 format!("merge pull request #{number}: {subject}")
1048 }
1049 }
1050
1051 fn approval_detail(
1053 &self,
1054 url: &str,
1055 base: &str,
1056 subject: &str,
1057 contested: Option<&ContestedHandoff>,
1058 ) -> String {
1059 let body = if self.html_lang == "ja" {
1060 format!(
1061 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1062 できる状態です。差分の要約・パッチ・squash されるコミットは\
1063 下のパネルにあります。"
1064 )
1065 } else {
1066 format!(
1067 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1068 The panel holds the diffstat, the patch and the commits being squashed."
1069 )
1070 };
1071 match contested {
1072 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1073 None => body,
1074 }
1075 }
1076
1077 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1080 const SHOWN: usize = 5;
1081 const TITLE_CHARS: usize = 100;
1082 let ja = self.html_lang == "ja";
1083 let mut out = if ja {
1084 format!(
1085 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1086 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1087 )
1088 } else {
1089 format!(
1090 "{url} is held for approval although merge approvals are off: the \
1091 review ended with blocking findings still open and a reviewer \
1092 voting reject.\n"
1093 )
1094 };
1095 for f in c.findings.iter().take(SHOWN) {
1096 let at = match (&f.file, f.line) {
1097 (Some(file), Some(line)) => format!("{file}:{line}"),
1098 (Some(file), None) => file.clone(),
1099 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1100 };
1101 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1102 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1103 }
1104 if c.findings.len() > SHOWN {
1105 let more = c.findings.len() - SHOWN;
1106 let _ = writeln!(
1107 out,
1108 "{}",
1109 if ja {
1110 format!("- ほか {more} 件")
1111 } else {
1112 format!("- and {more} more")
1113 }
1114 );
1115 }
1116 let seats: Vec<String> = c
1117 .rejecters
1118 .iter()
1119 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1120 .collect();
1121 let _ = write!(
1122 out,
1123 "{} {}",
1124 if ja {
1125 "却下したレビュアー:"
1126 } else {
1127 "Rejected by reviewer:"
1128 },
1129 seats.join(", ")
1130 );
1131 out
1132 }
1133
1134 fn truncated_note(
1136 &self,
1137 omitted: usize,
1138 total: usize,
1139 shown: usize,
1140 where_: &str,
1141 base: &str,
1142 head: &str,
1143 ) -> String {
1144 if self.html_lang == "ja" {
1145 format!(
1146 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1147 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1148 プルリクエストにあります。"
1149 )
1150 } else {
1151 format!(
1152 "{omitted} of {total} diff lines omitted after the first {shown}. \
1153 The whole patch is in <code>{where_}</code> \
1154 (<code>git diff {base}...{head}</code>) and on the pull request."
1155 )
1156 }
1157 }
1158}
1159
1160fn words(language: &str) -> &'static Words {
1163 if crate::lang::is_japanese(language) {
1164 &JA
1165 } else {
1166 &EN
1167 }
1168}
1169
1170pub fn approval_panel(
1182 state: &RunState,
1183 pr: &PrState,
1184 diffstat: &str,
1185 diff: &str,
1186 commits: &[String],
1187 subject: &str,
1188) -> String {
1189 let rows = parse_numstat(diffstat);
1190 let w = words(&state.config.graph.language);
1191 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1192
1193 let _ = writeln!(
1194 h,
1195 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1196 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1197 w.html_lang
1198 );
1199 let _ = writeln!(
1200 h,
1201 "<title>merge #{} — {}</title>\n</head>",
1202 pr.number,
1203 esc(subject)
1204 );
1205 h.push_str(
1206 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1207 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1208 word-break:break-word\">\n",
1209 );
1210
1211 let _ = writeln!(
1213 h,
1214 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1215 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1216 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1217 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1218 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1219 pr.number,
1220 esc(&state.base_branch),
1221 esc(subject),
1222 esc(&state.id),
1223 esc(&pr.url),
1224 esc(&pr.url),
1225 );
1226
1227 let _ = writeln!(
1230 h,
1231 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1232 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1233 w.task,
1234 esc(&state.instruction)
1235 );
1236
1237 if let Some(summary) = state
1239 .winner()
1240 .map(|c| c.summary.as_str())
1241 .filter(|s| !s.is_empty())
1242 {
1243 let _ = writeln!(
1244 h,
1245 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1246 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1247 w.what_changed,
1248 esc(summary)
1249 );
1250 }
1251
1252 if let Some(round) = state.reviews.last() {
1255 let _ = writeln!(
1256 h,
1257 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1258 w.review_verdict
1259 );
1260 for r in &round.reviews {
1261 let body = match &r.failed {
1268 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1269 None => esc(&r.summary),
1270 };
1271 let _ = writeln!(
1272 h,
1273 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1274 border-radius:6px\">\
1275 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1276 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1277 w.reviewer,
1278 r.reviewer,
1279 esc(&r.agent),
1280 body,
1281 );
1282 }
1283 }
1284
1285 let _ = writeln!(
1286 h,
1287 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1288 w.checks,
1289 esc(pr.checks.as_str())
1290 );
1291 if pr.failing.is_empty() {
1292 let _ = writeln!(
1293 h,
1294 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1295 w.nothing_failing
1296 );
1297 } else {
1298 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1299 for f in &pr.failing {
1300 let _ = writeln!(h, "<li>{}</li>", esc(f));
1301 }
1302 h.push_str("</ul>\n");
1303 }
1304
1305 let _ = writeln!(
1308 h,
1309 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1310 rows.len(),
1311 w.files_changed
1312 );
1313 h.push_str(
1314 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1315 <thead><tr>\
1316 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1317 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1318 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1319 </th></tr></thead>\n<tbody>\n",
1320 );
1321 let mut total_added = 0u64;
1322 let mut total_removed = 0u64;
1323 for r in &rows {
1324 total_added += r.added.unwrap_or(0);
1325 total_removed += r.removed.unwrap_or(0);
1326 let cell = |n: Option<u64>| match n {
1327 Some(n) => n.to_string(),
1328 None => "bin".to_owned(),
1329 };
1330 let _ = writeln!(
1331 h,
1332 "<tr>\
1333 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1334 font-family:ui-monospace,monospace\">{}</td>\
1335 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1336 color:#0a3622\">{}</td>\
1337 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1338 color:#5c1a17\">{}</td></tr>",
1339 esc(&r.path),
1340 cell(r.added),
1341 cell(r.removed),
1342 );
1343 }
1344 let _ = writeln!(
1345 h,
1346 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1347 <td style=\"padding:4px 2px\">total</td>\
1348 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1349 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1350 </tr></tfoot>\n</table>"
1351 );
1352
1353 let _ = writeln!(
1355 h,
1356 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1357 w.commits
1358 );
1359 if commits.is_empty() {
1360 h.push_str(&format!(
1361 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1362 w.no_commits
1363 ));
1364 } else {
1365 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1366 for c in commits {
1367 let _ = writeln!(h, "<li>{}</li>", esc(c));
1368 }
1369 h.push_str("</ol>\n");
1370 }
1371 let _ = writeln!(
1372 h,
1373 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1374 w.lands_as,
1375 esc(subject),
1376 w.lands_as_tail()
1377 );
1378
1379 let _ = writeln!(
1381 h,
1382 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1383 w.comments
1384 );
1385 if pr.review_comments.is_empty() {
1386 h.push_str(&format!(
1387 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1388 w.no_comments
1389 ));
1390 } else {
1391 for c in &pr.review_comments {
1392 let anchor = match (&c.path, c.line) {
1393 (Some(p), Some(l)) => format!("{p}:{l}"),
1394 (Some(p), None) => p.clone(),
1395 _ => "pull request thread".to_owned(),
1396 };
1397 let _ = writeln!(
1398 h,
1399 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1400 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1401 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1402 esc(&c.author),
1403 esc(&anchor),
1404 esc(&tail(&c.body, 800)),
1405 );
1406 }
1407 }
1408
1409 let total = diff.lines().count();
1411 let shown = total.min(DIFF_MAX_LINES);
1412 let _ = writeln!(
1413 h,
1414 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1415 w.diff
1416 );
1417 h.push_str(
1418 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1419 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1420 );
1421 for line in diff.lines().take(shown) {
1422 let (gutter, style, body) = diff_row(line);
1423 let _ = writeln!(
1424 h,
1425 "<div style=\"display:flex;{style}\">\
1426 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1427 border-right:1px solid #d0d7de\">{gutter}</span>\
1428 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1429 esc(body),
1430 );
1431 }
1432 h.push_str("</div>\n");
1433 if total > shown {
1434 let omitted = total - shown;
1435 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1436 let where_ = state.winner().map_or_else(
1437 || state.repo.display().to_string(),
1438 |w| w.worktree.display().to_string(),
1439 );
1440 let _ = writeln!(
1441 h,
1442 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1443 font-size:13px\">{}: {}</p>",
1444 w.truncated,
1445 w.truncated_note(
1446 omitted,
1447 total,
1448 shown,
1449 &esc(&where_),
1450 &esc(&state.base_branch),
1451 &esc(head),
1452 ),
1453 );
1454 }
1455
1456 h.push_str("</body>\n</html>\n");
1457 h
1458}
1459
1460fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1464 if state.config.graph.hold_contested_merge {
1465 state.contested_handoff.clone()
1466 } else {
1467 None
1468 }
1469}
1470
1471pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1480 let mut s = format!(
1481 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1482 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1483 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1484 request open. Silence is a hold: the owner not answering never merges. Only \
1485 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1486 own words, merges. Whether their wording is a clear, unconditional instruction \
1487 is your judgement alone: if it is doubtful, conditional, retracted or a \
1488 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1489 silence are a hold.\n\n\
1490 This brief is a snapshot from when you were attached: check `magi show {run}` \
1491 and `gh pr view` (read-only) before telling the owner anything current. \
1492 You run with permission to write the question record, and what keeps you \
1493 from touching anything else is this brief and your instructions - so do \
1494 not change files, branches or the pull request.",
1495 run = q.run
1496 );
1497 if let Some(target) = &q.merge_target {
1498 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));
1499 }
1500 if q.choices.iter().any(|c| c == REPAIR) {
1501 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.");
1502 }
1503 let Some(state) = state else {
1504 s.push_str(
1505 "\n\nThe run's record could not be read, so the pull request, the panel \
1506 summary and any contested findings are not known to you beyond the \
1507 question's own text. Say so to the owner rather than guessing.",
1508 );
1509 return s;
1510 };
1511 if let Some(pr) = &state.pr {
1512 s.push_str(&format!(
1513 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1514 pr.number, pr.url, pr.state
1515 ));
1516 }
1517 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1518 if let Some(w) = state.winner() {
1519 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1520 }
1521 match contested_to_ask(state) {
1522 Some(c) => {
1523 s.push_str(
1524 "\n\nThis question was filed although merge approvals are off, because \
1525 the review hand-off is contested. Open findings:",
1526 );
1527 for f in &c.findings {
1528 let at = match (&f.file, f.line) {
1529 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1530 (Some(file), None) => format!(" ({file})"),
1531 _ => String::new(),
1532 };
1533 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1534 }
1535 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1536 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1537 }
1538 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1539 }
1540 s
1541}
1542
1543fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1547 if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1548 return Ok(false);
1549 }
1550 let Some(task_id) = state
1551 .origin
1552 .as_ref()
1553 .and_then(|origin| origin.task.as_ref())
1554 else {
1555 return Ok(false);
1556 };
1557 let task = crate::queue::Queue::open()
1558 .get(task_id)
1559 .context("cannot verify operator decisions for the merge approval")?;
1560 Ok(task
1561 .operator_decided_at
1562 .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1563}
1564
1565async fn approval_gate(
1585 state: &mut RunState,
1586 pr: &PrState,
1587 subject: &str,
1588 contested: Option<&ContestedHandoff>,
1589 head: &str,
1590) -> Result<ApprovalGate> {
1591 let store = ask::Questions::open();
1592 let reusable = state.land_approval.as_ref().and_then(|a| {
1597 store
1598 .list()
1599 .into_iter()
1600 .find(|q| q.id == a.question)
1601 .filter(|q| {
1602 a.head.eq_ignore_ascii_case(head)
1603 || q.resolution()
1604 .as_deref()
1605 .is_some_and(crate::merge_decision::stops)
1606 })
1607 });
1608 let reusable = match reusable {
1609 Some(q) if merge_consent_superseded(state, &q)? => None,
1610 other => other,
1611 };
1612 if reusable.is_none() {
1613 for stale in store
1614 .list()
1615 .into_iter()
1616 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1617 {
1618 let why =
1619 "the pull request head or operator decision changed; asked again for approval";
1620 if let Err(e) = store.update(&stale.id, |q| {
1621 q.abandon(why);
1622 Ok(())
1623 }) {
1624 tracing::warn!("could not retire the superseded approval question: {e:#}");
1625 }
1626 }
1627 }
1628
1629 let q = match reusable {
1630 Some(mut q) => {
1631 if upgrade_repair_choice(&mut q, state) {
1632 q = store
1633 .update(&q.id, |current| {
1634 upgrade_repair_choice(current, state);
1635 Ok(())
1636 })?
1637 .0;
1638 }
1639 q
1640 }
1641 None => {
1642 let worktree = match state.winner() {
1643 Some(w) => w.worktree.clone(),
1644 None => state.repo.clone(),
1645 };
1646 let head = if head.is_empty() {
1649 state
1650 .winner()
1651 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1652 } else {
1653 head.to_owned()
1654 };
1655 let remote = &state.config.merge.remote;
1658 let tracking = format!("{remote}/{}", state.base_branch);
1659 let base = if git::rev_exists(&worktree, &tracking).await {
1660 tracking
1661 } else {
1662 String::new()
1663 };
1664 let range = format!("{base}...{head}");
1665 let numstat = if base.is_empty() {
1669 String::new()
1670 } else {
1671 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1672 .await
1673 .map(|o| o.stdout)
1674 .unwrap_or_default()
1675 };
1676 let diff = if base.is_empty() {
1677 String::new()
1678 } else {
1679 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1680 };
1681 let commits: Vec<String> = if base.is_empty() {
1682 Vec::new()
1683 } else {
1684 git::git_raw(
1685 &worktree,
1686 &[
1687 "log",
1688 "--reverse",
1689 "--format=%s",
1690 &format!("{base}..{head}"),
1691 ],
1692 )
1693 .await
1694 .map(|o| o.stdout)
1695 .unwrap_or_default()
1696 .lines()
1697 .filter(|l| !l.trim().is_empty())
1698 .map(str::to_owned)
1699 .collect()
1700 };
1701
1702 let w = words(&state.config.graph.language);
1703 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1704 let mut fresh = ask::Question::new(
1705 state.id.clone(),
1706 APPROVAL_NODE.to_owned(),
1707 "land".to_owned(),
1708 w.approval_summary(pr.number, subject),
1709 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1710 approval_choices(state),
1711 );
1712 fresh.merge_target = Some(crate::merge_decision::Target {
1713 pr: pr.url.clone(),
1714 head: head.clone(),
1715 language: state.config.graph.language.clone(),
1716 });
1717 if fresh.choices.iter().any(|c| c == REPAIR) {
1718 fresh.actions.insert(
1719 REPAIR.to_owned(),
1720 ask::ChoiceAction::Resume {
1721 run: state.id.clone(),
1722 },
1723 );
1724 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.");
1725 }
1726 for choice in fresh.choices.clone() {
1727 if crate::merge_decision::stops(&choice) && choice != crate::merge_decision::DISMISS
1728 {
1729 fresh.actions.insert(
1730 choice,
1731 ask::ChoiceAction::Resume {
1732 run: state.id.clone(),
1733 },
1734 );
1735 }
1736 }
1737 store
1738 .put_panel(&mut fresh, &html, &[])
1739 .context("write the merge approval panel")?;
1740 store
1741 .put(&mut fresh)
1742 .context("file the merge approval question")?;
1743 state.land_approval = Some(LandApproval {
1744 question: fresh.id.clone(),
1745 head: head.clone(),
1746 });
1747 state.event(
1748 "land",
1749 format!("asking for merge approval ({})", fresh.short()),
1750 );
1751 state.save()?;
1752 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1753 tracing::warn!(
1757 "could not notify about merge approval question {}: {e:#} - \
1758 the web UI is the only surface for it now",
1759 fresh.short()
1760 );
1761 }
1762 fresh
1763 }
1764 };
1765
1766 Ok(match q.status {
1767 ask::QuestionStatus::Open => ApprovalGate::Pending,
1768 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1772 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1776 Approval::Merge => ApprovalGate::Approved,
1777 Approval::Repair if !state.reviews.is_empty() => {
1778 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1779 state.merge_repairs.push(crate::run::MergeRepair {
1780 question: q.id.clone(),
1781 head: head.to_owned(),
1782 pr: pr.url.clone(),
1783 reviews: state.reviews.clone(),
1784 gate: state.gate.clone(),
1785 gate_head: state.gate_head.clone(),
1786 gate_fixes: state.gate_fixes.clone(),
1787 pre_gate: state.pre_gate.clone(),
1788 phase: crate::run::MergeRepairPhase::Requested,
1789 failure: None,
1790 });
1791 state.land_approval = None;
1792 state.event(
1793 "repair",
1794 "owner requested fix findings and re-review; merge is not approved",
1795 );
1796 state.save()?;
1797 }
1798 ApprovalGate::Repair
1799 }
1800 Approval::Repair | Approval::Hold => ApprovalGate::Held,
1801 },
1802 })
1803}
1804
1805fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1808 let mut failing = Vec::new();
1809 let mut pending = false;
1810 let mut unknown = false;
1811 for check in rollup {
1812 match check.verdict() {
1813 Verdict::Pass => {}
1814 Verdict::Pending => pending = true,
1815 Verdict::Fail => failing.push(check.label()),
1816 Verdict::Unknown => unknown = true,
1817 }
1818 }
1819 let checks = if rollup.is_empty() {
1820 Checks::Unknown
1821 } else if pending {
1822 Checks::Pending
1823 } else if !failing.is_empty() {
1824 Checks::Red
1825 } else if unknown {
1826 Checks::Unknown
1827 } else {
1828 Checks::Green
1829 };
1830 (checks, failing)
1831}
1832
1833pub fn parse_pr(json: &str) -> Result<PrState> {
1836 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1837 let state = match raw.state.to_ascii_uppercase().as_str() {
1838 "OPEN" => PrLifecycle::Open,
1839 "MERGED" => PrLifecycle::Merged,
1840 "CLOSED" => PrLifecycle::Closed,
1841 other => bail!("unknown pull request state `{other}`"),
1842 };
1843
1844 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1845
1846 let mut review_comments = Vec::new();
1847 for r in raw.reviews {
1848 push_if_outstanding(
1849 &mut review_comments,
1850 ReviewComment {
1851 author: r.author.login,
1852 path: None,
1853 line: None,
1854 body: r.body,
1855 },
1856 );
1857 }
1858 for c in raw.comments {
1859 push_if_outstanding(
1860 &mut review_comments,
1861 ReviewComment {
1862 author: c.author.login,
1863 path: None,
1864 line: None,
1865 body: c.body,
1866 },
1867 );
1868 }
1869
1870 Ok(PrState {
1871 url: raw.url,
1872 number: raw.number,
1873 state,
1874 checks,
1875 failing,
1876 review_comments,
1877 blocking: Blocking::of(&raw.merge_state_status),
1878 })
1879}
1880
1881#[derive(Debug, Clone, PartialEq, Eq)]
1883pub(crate) struct CheckView {
1884 pub name: String,
1885 pub verdict: Verdict,
1886 pub run: Option<String>,
1888 pub url: Option<String>,
1889}
1890
1891#[derive(Debug, Clone, PartialEq, Eq)]
1895pub(crate) struct RollupView {
1896 pub url: String,
1897 pub number: u64,
1898 pub state: PrLifecycle,
1899 pub head: String,
1900 pub checks: Vec<CheckView>,
1901}
1902
1903pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1906 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1907 let state = match raw.state.to_ascii_uppercase().as_str() {
1908 "OPEN" => PrLifecycle::Open,
1909 "MERGED" => PrLifecycle::Merged,
1910 "CLOSED" => PrLifecycle::Closed,
1911 other => bail!("unknown pull request state `{other}`"),
1912 };
1913 let checks = raw
1914 .status_check_rollup
1915 .iter()
1916 .map(|c| CheckView {
1917 name: c.label(),
1918 verdict: c.verdict(),
1919 run: c.url().and_then(run_of),
1920 url: c.url().map(str::to_owned),
1921 })
1922 .collect();
1923 Ok(RollupView {
1924 url: raw.url,
1925 number: raw.number,
1926 state,
1927 head: raw.head_ref_oid,
1928 checks,
1929 })
1930}
1931
1932pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1942 let view = gh(
1943 repo,
1944 &[
1945 "pr".to_owned(),
1946 "view".to_owned(),
1947 pr_url.to_owned(),
1948 "--json".to_owned(),
1949 "state".to_owned(),
1950 ],
1951 )
1952 .await?;
1953 if !view.0 {
1954 bail!("gh pr view {pr_url}: {}", view.1);
1955 }
1956 Ok(parse_pr(&view.1)?.state)
1960}
1961
1962#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1966pub struct ExternalMerge {
1967 pub url: String,
1969 pub number: u64,
1971}
1972
1973#[derive(Debug, Deserialize)]
1974#[serde(rename_all = "camelCase")]
1975struct GhMergedPr {
1976 url: String,
1977 number: u64,
1978 merged_at: String,
1979 base_ref_name: String,
1980}
1981
1982fn pick_merged_pr(
1996 json: &str,
1997 base_branch: &str,
1998 created_at: Timestamp,
1999) -> Result<Option<ExternalMerge>> {
2000 let raw: Vec<GhMergedPr> =
2001 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2002 let mut matches: Vec<ExternalMerge> = Vec::new();
2003 for pr in raw {
2004 if pr.base_ref_name != base_branch {
2005 continue;
2006 }
2007 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2008 continue;
2009 };
2010 if merged_at < created_at {
2011 continue;
2012 }
2013 matches.push(ExternalMerge {
2014 url: pr.url,
2015 number: pr.number,
2016 });
2017 }
2018 if matches.len() == 1 {
2019 Ok(matches.pop())
2020 } else {
2021 Ok(None)
2022 }
2023}
2024
2025#[derive(Debug, Clone, PartialEq, Eq)]
2027pub enum OpenPr {
2028 None,
2030 One {
2032 url: String,
2034 title: String,
2036 },
2037 Many(Vec<String>),
2039}
2040
2041#[derive(Debug, Deserialize)]
2042#[serde(rename_all = "camelCase")]
2043struct GhOpenPr {
2044 url: String,
2048 #[serde(default)]
2049 title: String,
2050 base_ref_name: String,
2051 #[serde(default)]
2054 is_cross_repository: bool,
2055}
2056
2057pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2063 let raw: Vec<GhOpenPr> =
2064 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2065 let mut hits: Vec<GhOpenPr> = raw
2066 .into_iter()
2067 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2068 .collect();
2069 Ok(match hits.len() {
2070 0 => OpenPr::None,
2071 1 => {
2072 let p = hits.remove(0);
2073 OpenPr::One {
2074 url: p.url,
2075 title: p.title,
2076 }
2077 }
2078 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2079 })
2080}
2081
2082pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2086 let (ok, out) = gh(
2087 repo,
2088 &[
2089 "pr".to_owned(),
2090 "list".to_owned(),
2091 "--head".to_owned(),
2092 branch.to_owned(),
2093 "--base".to_owned(),
2094 base.to_owned(),
2095 "--state".to_owned(),
2096 "open".to_owned(),
2097 "--json".to_owned(),
2098 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2099 ],
2100 )
2101 .await?;
2102 if !ok {
2103 bail!("gh pr list failed: {out}");
2104 }
2105 pick_open_pr(&out, base)
2106}
2107
2108#[derive(Debug, Deserialize)]
2109#[serde(rename_all = "camelCase")]
2110struct GhPrHead {
2111 head_ref_name: String,
2112 base_ref_name: String,
2113 state: String,
2114 is_cross_repository: bool,
2117 head_ref_oid: String,
2120}
2121
2122#[derive(Debug, Clone, PartialEq, Eq)]
2124pub struct Refusal {
2125 pub retry: bool,
2128 pub why: String,
2130}
2131
2132impl Refusal {
2133 fn final_(why: String) -> Self {
2134 Self { retry: false, why }
2135 }
2136}
2137
2138pub fn closable(
2147 json: &str,
2148 branch: &str,
2149 base: &str,
2150 verified: &[String],
2151) -> std::result::Result<(), Refusal> {
2152 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2153 retry: true,
2154 why: format!("could not read the pull request ({e})"),
2155 })?;
2156 if pr.head_ref_name != branch {
2157 return Err(Refusal::final_(format!(
2158 "its head is `{}`, not this run's `{branch}`",
2159 pr.head_ref_name
2160 )));
2161 }
2162 if pr.is_cross_repository {
2163 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2164 }
2165 if pr.base_ref_name != base {
2166 return Err(Refusal::final_(format!(
2167 "it targets `{}`, not `{base}`",
2168 pr.base_ref_name
2169 )));
2170 }
2171 if !pr.state.eq_ignore_ascii_case("open") {
2172 return Err(Refusal::final_(format!(
2173 "it is already {}",
2174 pr.state.to_ascii_lowercase()
2175 )));
2176 }
2177 if !verified.contains(&pr.head_ref_oid) {
2181 return Err(Refusal {
2182 retry: true,
2183 why: format!(
2184 "its head {} is not a commit this run checked against the base",
2185 crate::already::short_sha(&pr.head_ref_oid)
2186 ),
2187 });
2188 }
2189 Ok(())
2190}
2191
2192async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2197 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2198 return false;
2199 };
2200 is_forge_url(url.trim())
2201}
2202
2203fn is_forge_url(url: &str) -> bool {
2204 url.contains("://") && !url.starts_with("file://")
2205 || url
2206 .split_once(':')
2207 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2208}
2209
2210fn forge_unavailable(message: &str) -> bool {
2213 message.contains("known GitHub host") || message.contains("spawn gh")
2214}
2215
2216pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2219 let how = match evidence.proof {
2220 crate::already::Proof::PatchId => format!(
2221 "carried by commit {} on `{base}` with the same patch",
2222 evidence.names()
2223 ),
2224 crate::already::Proof::Ancestry => {
2225 format!("already in the history of `{base}` as {}", evidence.names())
2226 }
2227 crate::already::Proof::Tree => format!(
2228 "already part of `{base}` (merging this branch changes nothing at {})",
2229 crate::already::short_sha(&evidence.tip)
2230 ),
2231 };
2232 format!(
2233 "Closing: everything this branch adds is {how}, so there is nothing left to \
2234 land. This pull request was closed automatically after that was verified; \
2235 reopen it if you disagree."
2236 )
2237}
2238
2239pub async fn close_superseded_pr(
2251 state: &mut RunState,
2252 branch: &str,
2253 evidence: &crate::already::Evidence,
2254 verified: &[String],
2255) -> Result<std::result::Result<String, String>> {
2256 let repo = state.repo.clone();
2257 let base = state.base_branch.clone();
2258 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2259 Some(p) => p.url.clone(),
2260 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2265 return Ok(Err(
2266 "the remote is not a forge, so there is no pull request".to_owned(),
2267 ));
2268 }
2269 None => match find_open_pr(&repo, branch, &base).await {
2270 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2271 return Ok(Err(format!("no forge to ask: {e:#}")));
2272 }
2273 Err(e) => return Err(e),
2274 Ok(OpenPr::One { url, .. }) => url,
2275 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2276 Ok(OpenPr::Many(urls)) => {
2277 return Ok(Err(format!(
2278 "{} open pull requests name it; not choosing between them",
2279 urls.len()
2280 )));
2281 }
2282 },
2283 };
2284 let (ok, view) = gh(
2285 &repo,
2286 &[
2287 "pr".to_owned(),
2288 "view".to_owned(),
2289 url.clone(),
2290 "--json".to_owned(),
2291 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2292 ],
2293 )
2294 .await?;
2295 if !ok {
2296 bail!("gh pr view {url} failed: {view}");
2297 }
2298 if let Err(refusal) = closable(&view, branch, &base, verified) {
2299 if refusal.retry {
2302 bail!("left {url} open: {}", refusal.why);
2303 }
2304 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2305 }
2306 let (ok, out) = gh(
2307 &repo,
2308 &[
2309 "pr".to_owned(),
2310 "close".to_owned(),
2311 url.clone(),
2312 "--comment".to_owned(),
2313 superseded_comment(&base, evidence),
2314 ],
2315 )
2316 .await?;
2317 if !ok {
2318 bail!("gh pr close {url} failed: {out}");
2319 }
2320 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2321 p.state = "closed".to_owned();
2322 }
2323 Ok(Ok(url))
2324}
2325
2326pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2330 let (title, _) = crate::github_text::prepare(state, title, "");
2331 edit_pr_title(repo, url, &title).await
2332}
2333
2334pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2338 edit_pr_title(repo, url, title).await
2339}
2340
2341pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2344 let (ok, _) = gh(
2345 repo,
2346 &[
2347 "pr".to_owned(),
2348 "edit".to_owned(),
2349 url.to_owned(),
2350 "--title".to_owned(),
2351 title.to_owned(),
2352 "--body".to_owned(),
2353 body.to_owned(),
2354 ],
2355 )
2356 .await
2357 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2358 if !ok {
2359 bail!("gh pr edit failed while posting the chosen rewrite");
2361 }
2362 Ok(())
2363}
2364
2365async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2366 let (ok, out) = gh(
2367 repo,
2368 &[
2369 "pr".to_owned(),
2370 "edit".to_owned(),
2371 url.to_owned(),
2372 "--title".to_owned(),
2373 title.to_owned(),
2374 ],
2375 )
2376 .await?;
2377 if !ok {
2378 bail!("gh pr edit failed: {out}");
2379 }
2380 Ok(())
2381}
2382
2383pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2396 let Some(winner) = state.winner() else {
2397 return Ok(None);
2398 };
2399 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2400 let out = gh(
2401 &state.repo,
2402 &[
2403 "pr".to_owned(),
2404 "list".to_owned(),
2405 "--head".to_owned(),
2406 branch.clone(),
2407 "--state".to_owned(),
2408 "merged".to_owned(),
2409 "--json".to_owned(),
2410 "url,number,mergedAt,baseRefName".to_owned(),
2411 ],
2412 )
2413 .await?;
2414 if !out.0 {
2415 bail!("gh pr list --head {branch}: {}", out.1);
2416 }
2417 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2418}
2419
2420pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2432 let out = tokio::process::Command::new("git")
2433 .args(["merge-base", "--is-ancestor", branch, base_branch])
2434 .current_dir(repo)
2435 .quiet()
2436 .stdin(std::process::Stdio::null())
2437 .output()
2438 .await
2439 .context("spawn git merge-base --is-ancestor")?;
2440 Ok(out.status.success())
2441}
2442
2443fn forge_slug(url: &str) -> Option<(String, &str)> {
2451 let rest = url.rsplit("://").next()?;
2452 let (host, path) = rest.split_once('/')?;
2453 if host.is_empty() {
2454 return None;
2455 }
2456 Some((host.to_ascii_lowercase(), path))
2457}
2458
2459pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2467 let (host, path) = forge_slug(url)?;
2468 let mut segments = path.split('/');
2469 let owner = segments.next()?;
2470 let repo = segments.next()?;
2471 let kind = segments.next()?;
2472 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2473 return None;
2474 }
2475 Some(format!("{host}/{owner}/{repo}"))
2476}
2477
2478fn slug_of_repo_url(url: &str) -> Option<String> {
2482 let (host, path) = forge_slug(url)?;
2483 let mut segments = path.split('/');
2484 let owner = segments.next()?;
2485 let repo = segments.next()?;
2486 if owner.is_empty() || repo.is_empty() {
2487 return None;
2488 }
2489 Some(format!("{host}/{owner}/{repo}"))
2490}
2491
2492pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2513 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2514 return Ok(());
2515 }
2516 bail!(
2517 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2518 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2519 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2520 verify by hand before treating this as a false positive)"
2521 );
2522}
2523
2524async fn repo_slug(repo: &Path) -> Result<String> {
2535 let out = gh(
2536 repo,
2537 &[
2538 "repo".to_owned(),
2539 "view".to_owned(),
2540 "--json".to_owned(),
2541 "url".to_owned(),
2542 ],
2543 )
2544 .await?;
2545 if !out.0 {
2546 bail!("gh repo view --json url: {}", out.1);
2547 }
2548 #[derive(Debug, Deserialize)]
2549 struct GhRepo {
2550 url: String,
2551 }
2552 let parsed: GhRepo = serde_json::from_str(&out.1)
2553 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2554 slug_of_repo_url(&parsed.url)
2555 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2556}
2557
2558pub async fn correct_manual_merge(
2606 state: &mut RunState,
2607 url: &str,
2608) -> Result<(RunStatus, RunStatus)> {
2609 let Some(pr_slug) = slug_of_pr_url(url) else {
2610 bail!(
2611 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2612 this pull request belongs to"
2613 );
2614 };
2615 let run_slug = repo_slug(&state.repo).await?;
2616 ensure_same_repo(&run_slug, &pr_slug)?;
2617 correct_merge(state, url).await
2618}
2619
2620pub(crate) async fn correct_confirmed_external_merge(
2631 state: &mut RunState,
2632 url: &str,
2633) -> Result<(RunStatus, RunStatus)> {
2634 correct_merge(state, url).await
2635}
2636
2637fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2639 let Some(pr) = a.pr.as_ref() else {
2640 return false;
2641 };
2642 if !url.is_empty()
2643 && pr
2644 .url
2645 .trim_end_matches('/')
2646 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2647 {
2648 return true;
2649 }
2650 number > 0
2651 && pr.number == number
2652 && match (a.repo.canonicalize(), repo.canonicalize()) {
2653 (Ok(x), Ok(y)) => x == y,
2654 _ => a.repo == repo,
2655 }
2656}
2657
2658fn rewrite_open_prs(
2671 home: &Path,
2672 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2673) -> usize {
2674 let now = Timestamp::now();
2675 let mut changed = 0;
2676 for id in crate::run::list_ids_in(&home.join("runs")) {
2677 let path = home.join("runs").join(&id).join("run.json");
2678 let Ok(body) = std::fs::read_to_string(&path) else {
2679 continue;
2680 };
2681 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2682 continue;
2683 };
2684 if !state.status.done()
2685 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2686 || crate::daemon::is_working_on(home, &id, now)
2687 {
2688 continue;
2689 }
2690 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2691 continue;
2692 };
2693 if let Some(pr) = state.pr.as_mut() {
2694 pr.state = to.as_str().to_owned();
2695 }
2696 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2697 state.event(
2698 "land",
2699 format!("recorded {url} as {}: another run settled it", to.as_str()),
2700 );
2701 match state.save_under(home) {
2702 Ok(()) => changed += 1,
2703 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2704 }
2705 }
2706 changed
2707}
2708
2709pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2714 if to == PrLifecycle::Open {
2715 return;
2716 }
2717 let Some(home) = crate::run::try_home() else {
2718 return;
2719 };
2720 write_pr_state_through_in(&home, run, to);
2721}
2722
2723pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2724 let Some(pr) = run.pr.as_ref() else {
2725 return 0;
2726 };
2727 let (url, number) = (pr.url.clone(), pr.number);
2728 rewrite_open_prs(home, &mut |other| {
2729 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2730 })
2731}
2732
2733pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2736 let now = Timestamp::now();
2737 let mut out = Vec::new();
2738 for id in crate::run::list_ids_in(&home.join("runs")) {
2739 let path = home.join("runs").join(&id).join("run.json");
2740 let Ok(body) = std::fs::read_to_string(&path) else {
2741 continue;
2742 };
2743 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2744 continue;
2745 };
2746 if let Some(pr) = state.pr.as_ref()
2747 && state.status.done()
2748 && pr.state == "open"
2749 && !pr.url.is_empty()
2750 && !crate::daemon::is_working_on(home, &id, now)
2751 {
2752 out.push((id, state.repo.clone(), pr.url.clone()));
2753 }
2754 }
2755 out
2756}
2757
2758pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2761 rewrite_open_prs(home, &mut |s| {
2762 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2763 })
2764}
2765
2766pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2773 let mut known = BTreeMap::new();
2774 let mut failed = 0;
2775 let mut seen = BTreeSet::new();
2776 for (_, repo, url) in stale_open_prs(home) {
2777 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2778 continue;
2779 }
2780 match lifecycle(&repo, &url).await {
2781 Ok(state) => {
2782 known.insert(url, state);
2783 }
2784 Err(e) => {
2785 tracing::warn!("repair pr state of {url}: {e:#}");
2786 failed += 1;
2787 }
2788 }
2789 }
2790 (apply_pr_states(home, &known), failed)
2791}
2792
2793async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2794 match lifecycle(&state.repo, url).await? {
2795 PrLifecycle::Merged => {}
2796 other => bail!(
2797 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2798 other.as_str(),
2799 state.id
2800 ),
2801 }
2802 let before = state.status;
2803 if let Err(e) = land(state, url).await {
2804 state.status = RunStatus::Blocked;
2811 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2812 state.save()?;
2813 return Err(e).context(format!("confirming the merge of {url}"));
2814 }
2815 state.event(
2816 "fold",
2817 "operator recorded this pull request as a manual merge; this run never \
2818 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2819 bump this change might warrant has to be filed by hand",
2820 );
2821 if state.status == RunStatus::Merged {
2824 crate::followup::after_merge(state, url).await;
2825 }
2826 state.save()?;
2827 Ok((before, state.status))
2828}
2829
2830pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2837 let raw: Vec<GhInline> =
2838 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2839 let mut out = Vec::new();
2840 for c in raw {
2841 push_if_outstanding(
2842 &mut out,
2843 ReviewComment {
2844 author: c.user.login,
2845 path: c.path,
2846 line: c.line,
2847 body: c.body,
2848 },
2849 );
2850 }
2851 Ok(out)
2852}
2853
2854fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2860 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2861 return;
2862 }
2863 if comment.path.is_none() && is_noise(&comment.body) {
2864 return;
2865 }
2866 out.push(comment);
2867}
2868
2869pub fn is_noise(body: &str) -> bool {
2887 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2888 return true;
2889 }
2890 let mut content = false;
2891 for line in strip_blocks(body).lines() {
2892 let line = unquote(line);
2893 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2894 continue;
2895 }
2896 content = true;
2897 break;
2898 }
2899 !content
2900}
2901
2902fn strip_blocks(body: &str) -> String {
2904 let mut out = String::with_capacity(body.len());
2905 let mut rest = body;
2906 loop {
2907 let open = ["<!--", "<details>"]
2908 .iter()
2909 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2910 .min_by_key(|(i, _)| *i);
2911 let Some((at, tag)) = open else {
2912 out.push_str(rest);
2913 return out;
2914 };
2915 out.push_str(&rest[..at]);
2916 let after = &rest[at + tag.len()..];
2917 let close = if tag == "<!--" { "-->" } else { "</details>" };
2918 match after.find(close) {
2919 Some(end) => rest = &after[end + close.len()..],
2920 None => return out,
2922 }
2923 }
2924}
2925
2926fn unquote(line: &str) -> &str {
2928 let mut s = line.trim();
2929 while let Some(rest) = s.strip_prefix('>') {
2930 s = rest.trim_start();
2931 }
2932 s.trim()
2933}
2934
2935fn is_checklist(line: &str) -> bool {
2937 let rest = line
2938 .strip_prefix("- ")
2939 .or_else(|| line.strip_prefix("* "))
2940 .unwrap_or("");
2941 let rest = rest.trim_start();
2942 matches!(
2943 rest.get(..3),
2944 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2945 )
2946}
2947
2948fn is_decoration(line: &str) -> bool {
2950 line.starts_with('#')
2951 || line.starts_with("[!")
2952 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2953}
2954
2955fn is_banner(line: &str) -> bool {
2962 let plain = drop_spans(line, "**", "**");
2963 let plain = if plain.contains("](") {
2964 drop_spans(&plain, "[", ")")
2965 } else {
2966 plain
2967 };
2968 !plain.chars().any(char::is_alphanumeric)
2969}
2970
2971fn drop_spans(s: &str, open: &str, close: &str) -> String {
2975 let mut out = String::with_capacity(s.len());
2976 let mut rest = s;
2977 while let Some(at) = rest.find(open) {
2978 out.push_str(&rest[..at]);
2979 let after = &rest[at + open.len()..];
2980 match after.find(close) {
2981 Some(end) => rest = &after[end + close.len()..],
2982 None => return out,
2983 }
2984 }
2985 out.push_str(rest);
2986 out
2987}
2988
2989fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3007 static LOCKS: std::sync::LazyLock<
3008 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3009 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3010 LOCKS
3011 .lock()
3012 .unwrap_or_else(std::sync::PoisonError::into_inner)
3013 .entry(repo.to_path_buf())
3014 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3015 .clone()
3016}
3017
3018fn repo_label(repo: &Path, pr_url: &str) -> String {
3021 let parts: Vec<&str> = pr_url.split('/').collect();
3022 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3023 && at >= 2
3024 && !parts[at - 1].is_empty()
3025 && !parts[at - 2].is_empty()
3026 {
3027 return format!("{}/{}", parts[at - 2], parts[at - 1]);
3028 }
3029 repo.file_name()
3030 .map(|n| n.to_string_lossy().into_owned())
3031 .unwrap_or_default()
3032}
3033
3034fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3038 (pr.checks == Checks::Red).then(|| {
3039 format!(
3040 "Merged {repo_name} PR #{} with red checks: {} ({})",
3041 pr.number,
3042 if pr.failing.is_empty() {
3043 "(none named)".to_owned()
3044 } else {
3045 pr.failing.join(", ")
3046 },
3047 pr.url
3048 )
3049 })
3050}
3051
3052async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3056 let repo_name = repo_label(&state.repo, &pr.url);
3057 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3058 return;
3059 };
3060 if let Some(rec) = state.pr.as_mut() {
3061 rec.red_at_merge = pr.failing.clone();
3062 }
3063 state.event("land", summary.clone());
3064 crate::notices::raise_with(
3067 crate::notices::merged_red(&state.id, &summary),
3068 &state.config.notify,
3069 );
3070}
3071
3072pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3079 land_with(state, pr_url, &GhForge).await
3080}
3081
3082trait Forge {
3086 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3087 let (ok, out) = gh(
3088 repo,
3089 &[
3090 "pr".into(),
3091 "close".into(),
3092 pr_url.into(),
3093 "--comment".into(),
3094 comment.into(),
3095 ],
3096 )
3097 .await?;
3098 if !ok {
3099 bail!("PR closure failed: {out}");
3100 }
3101 Ok(())
3102 }
3103 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3104 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3105 async fn poll(&self);
3106 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3108 fn now(&self) -> i64;
3110 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3113 #[allow(clippy::too_many_arguments)]
3114 async fn fix(
3115 &self,
3116 state: &mut RunState,
3117 pr: &PrState,
3118 round: usize,
3119 budget: usize,
3120 reason: &str,
3121 logs: &str,
3122 ) -> Result<Fixed>;
3123}
3124
3125struct GhForge;
3126
3127impl Forge for GhForge {
3128 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3129 observe(repo, pr_url).await
3130 }
3131 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3132 gh(repo, argv).await
3133 }
3134 async fn poll(&self) {
3135 tokio::time::sleep(POLL).await;
3136 }
3137 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3138 let args = [
3139 "run".to_owned(),
3140 "rerun".to_owned(),
3141 run.to_owned(),
3142 "--failed".to_owned(),
3143 ];
3144 let (ok, out) = gh(repo, &args).await?;
3145 if !ok {
3146 bail!("gh run rerun {run}: {out}");
3147 }
3148 Ok(())
3149 }
3150 fn now(&self) -> i64 {
3151 jiff::Timestamp::now().as_second()
3152 }
3153 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3154 required_contexts_of(repo, base).await
3155 }
3156 async fn fix(
3157 &self,
3158 state: &mut RunState,
3159 pr: &PrState,
3160 round: usize,
3161 budget: usize,
3162 reason: &str,
3163 logs: &str,
3164 ) -> Result<Fixed> {
3165 fix_round(state, pr, round, budget, reason, logs).await
3166 }
3167}
3168
3169fn encode_path_segment(s: &str) -> String {
3171 let mut out = String::new();
3172 for b in s.bytes() {
3173 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3174 out.push(b as char);
3175 } else {
3176 let _ = write!(out, "%{b:02X}");
3177 }
3178 }
3179 out
3180}
3181
3182async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3187 let enc = encode_path_segment(base);
3188 let mut all = BTreeSet::new();
3189 let classic = gh(
3192 repo,
3193 &[
3194 "api".to_owned(),
3195 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3196 ],
3197 )
3198 .await
3199 .ok()?;
3200 if classic.0 {
3201 all.extend(parse_classic_required(&classic.1)?);
3202 } else if !classic.1.contains("404") {
3203 return None;
3204 }
3205 let rules = gh(
3206 repo,
3207 &[
3208 "api".to_owned(),
3209 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3210 ],
3211 )
3212 .await
3213 .ok()?;
3214 if !rules.0 {
3215 return None;
3216 }
3217 all.extend(parse_ruleset_required(&rules.1)?);
3218 Some(all)
3219}
3220
3221fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3224 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3225 let mut out = BTreeSet::new();
3226 for c in v.get("contexts")?.as_array()? {
3227 out.insert(c.as_str()?.to_owned());
3228 }
3229 for c in v
3230 .get("checks")
3231 .and_then(|c| c.as_array())
3232 .into_iter()
3233 .flatten()
3234 {
3235 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3236 out.insert(name.to_owned());
3237 }
3238 }
3239 Some(out)
3240}
3241
3242fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3244 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3245 let mut out = BTreeSet::new();
3246 for rule in v.as_array()? {
3247 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3248 continue;
3249 }
3250 let checks = rule
3251 .pointer("/parameters/required_status_checks")?
3252 .as_array()?;
3253 for c in checks {
3254 out.insert(c.get("context")?.as_str()?.to_owned());
3255 }
3256 }
3257 Some(out)
3258}
3259
3260fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3268 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3269}
3270
3271fn bound_head<'a>(
3281 seen_head: &'a str,
3282 rollup_head: &str,
3283 awaiting: Option<&str>,
3284) -> Option<&'a str> {
3285 if seen_head.is_empty()
3286 || awaiting_new_head(awaiting, seen_head)
3287 || !rollup_head.eq_ignore_ascii_case(seen_head)
3288 {
3289 return None;
3290 }
3291 Some(seen_head)
3292}
3293
3294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3296enum Refused {
3297 Pending,
3299 Recheck,
3303 Final,
3306}
3307
3308fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3311 let Some(after) = after else {
3312 return Refused::Pending;
3314 };
3315 if after.pr.state != PrLifecycle::Open {
3316 return Refused::Final;
3317 }
3318 if !after.head.eq_ignore_ascii_case(observed_head) {
3321 return Refused::Pending;
3322 }
3323 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3325 return Refused::Pending;
3326 }
3327 let state = after.merge_state.to_ascii_uppercase();
3328 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3329 || state.is_empty()
3330 || state == "UNKNOWN"
3331 {
3332 return Refused::Pending;
3333 }
3334 if rechecked {
3335 Refused::Final
3336 } else {
3337 Refused::Recheck
3338 }
3339}
3340
3341async fn disarm<F: Forge>(
3346 forge: &F,
3347 state: &mut RunState,
3348 repo: &Path,
3349 number: u64,
3350) -> std::result::Result<(), String> {
3351 let argv = disable_automerge_argv(number);
3352 let out = {
3353 let merge_lock = repo_merge_lock(repo);
3354 let _merge_slot = merge_lock.lock().await;
3355 forge.merge(repo, &argv).await
3356 };
3357 match out {
3358 Ok((true, _)) => {
3359 state.land_armed_head = None;
3360 state.event("land", "auto-merge disabled");
3361 state.save().map_err(|e| format!("{e:#}"))?;
3362 Ok(())
3363 }
3364 Ok((false, msg)) => Err(msg),
3365 Err(e) => Err(format!("{e:#}")),
3366 }
3367}
3368
3369async fn stop_disarmed<F: Forge>(
3373 forge: &F,
3374 state: &mut RunState,
3375 repo: &Path,
3376 pr: &PrState,
3377 why: &str,
3378) -> Result<()> {
3379 if state.land_armed_head.is_none() {
3380 return stop(state, repo, pr, why).await;
3381 }
3382 match disarm(forge, state, repo, pr.number).await {
3383 Ok(()) => stop(state, repo, pr, why).await,
3384 Err(e) => {
3385 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3386 stop(state, repo, pr, &why).await
3387 }
3388 }
3389}
3390
3391fn owner_requested_stop(state: &RunState) -> Result<bool> {
3393 let Some(binding) = &state.land_approval else {
3394 return Ok(false);
3395 };
3396 let q = ask::Questions::open().get(&binding.question)?;
3397 Ok(q.run == state.id
3398 && q.node == APPROVAL_NODE
3399 && q.resolution()
3400 .as_deref()
3401 .is_some_and(crate::merge_decision::stops))
3402}
3403
3404async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3407 use crate::merge_decision as md;
3408 let Some(binding) = &state.land_approval else {
3409 return Ok(false);
3410 };
3411 let q = ask::Questions::open().get(&binding.question)?;
3412 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3413 return Ok(false);
3414 };
3415 if q.run != state.id || q.node != APPROVAL_NODE {
3416 bail!("stop decision names another run");
3417 }
3418 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3419 pr: pr.url.clone(),
3420 head: binding.head.clone(),
3421 language: state.config.graph.language.clone(),
3422 });
3423 if target.pr != pr.url {
3424 bail!("stop decision names another PR");
3425 }
3426 if state.merge_decision.is_none() {
3427 state.merge_decision = Some(md::Decision {
3428 question: q.id.clone(),
3429 target,
3430 choice: choice.clone(),
3431 closed: false,
3432 replacement_run: None,
3433 replacement_pr: None,
3434 });
3435 state.save()?;
3436 }
3437 let repo = state.repo.clone();
3438 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3440 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3441 return Ok(true);
3442 }
3443 if choice.eq_ignore_ascii_case(HOLD) {
3444 stop(
3445 state,
3446 &repo,
3447 pr,
3448 "owner held the decision: PR left open; no further repair or merge work",
3449 )
3450 .await?;
3451 return Ok(true);
3452 }
3453 if md::selected_agent(&choice).is_some()
3454 || state
3455 .origin
3456 .as_ref()
3457 .and_then(|o| o.task.as_ref())
3458 .is_some()
3459 {
3460 let owners: Vec<_> = crate::queue::Queue::open()
3461 .list()
3462 .into_iter()
3463 .filter(|task| task.runs.last() == Some(&state.id))
3464 .collect();
3465 let owner = (owners.len() == 1).then(|| &owners[0]);
3466 if !owner.is_some_and(|task| {
3467 task.runs.last() == Some(&state.id)
3468 && (!task
3469 .operator_decided_at
3470 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3471 || task.resumption.as_ref().is_some_and(|release| {
3472 release.question_id.as_deref() == Some(q.id.as_str())
3473 && task.operator_decided_at == Some(release.at)
3474 }))
3475 }) {
3476 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3477 return Ok(true);
3478 }
3479 }
3480 if pr.state == PrLifecycle::Merged {
3481 stop(
3482 state,
3483 &repo,
3484 pr,
3485 "PR already merged; withdrawal/replacement cannot be applied",
3486 )
3487 .await?;
3488 return Ok(true);
3489 }
3490 let comment = format!(
3491 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3492 q.id, choice, state.id
3493 );
3494 if pr.state == PrLifecycle::Open {
3495 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3496 stop(
3497 state,
3498 &repo,
3499 pr,
3500 &format!(
3501 "owner requested closure but it failed: {e}; task held, no replacement started"
3502 ),
3503 )
3504 .await?;
3505 return Ok(true);
3506 }
3507 }
3508 let confirmed = forge.view(&repo, &pr.url).await?;
3509 if confirmed.pr.state != PrLifecycle::Closed {
3510 stop(
3511 state,
3512 &repo,
3513 pr,
3514 "PR closure was not confirmed; task held, no replacement started",
3515 )
3516 .await?;
3517 return Ok(true);
3518 }
3519 *pr = confirmed.pr;
3520 state.merge_decision.as_mut().unwrap().closed = true;
3521 if let Some(record) = &mut state.pr {
3522 record.state = "closed".into();
3523 }
3524 state.status = RunStatus::Superseded;
3525 state.merge = Some(MergeOutcome {
3526 mode: MergeMode::Pr,
3527 ok: false,
3528 detail: format!("owner decision: {choice}; PR closure confirmed"),
3529 empty: false,
3530 });
3531 state.event(
3532 "land",
3533 format!(
3534 "{} applied: PR closure confirmed; old execution retired",
3535 choice
3536 ),
3537 );
3538 state.save()?;
3539 Ok(true)
3540}
3541
3542async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3543 crate::pr_head::ensure_owner(state)?;
3544 let reopening_hold = crate::merge_decision::resumable_hold(state);
3545 let repo = state.repo.clone();
3546 let budget = state.config.graph.land_rounds;
3547 let mut round = 0usize;
3548 let mut rebases = 0usize;
3551 let mut waited = Duration::ZERO;
3552 let mut shown: BTreeSet<String> = BTreeSet::new();
3557 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3559 let mut rechecked = false;
3561
3562 state.status = RunStatus::Landing;
3570 state.event("land", format!("watching {pr_url}"));
3571 state.save()?;
3572
3573 let mut resumed_armed = state.land_armed_head.is_some();
3577
3578 loop {
3579 let seen = forge.view(&repo, pr_url).await?;
3580 let mut pr = seen.pr.clone();
3581 pr.review_comments.retain(|c| !shown.contains(&c.body));
3582 state.pr = Some(crate::run::PrRecord {
3583 url: pr.url.clone(),
3584 number: pr.number,
3585 state: pr.state.as_str().to_owned(),
3586 checks: pr.checks.as_str().to_owned(),
3587 round,
3588 rounds: budget,
3589 red_at_merge: Vec::new(),
3590 });
3591 state.save()?;
3592
3593 crate::pr_head::ensure_owner(state)?;
3594 if reopening_hold {
3595 anyhow::ensure!(
3596 pr.state == PrLifecycle::Open,
3597 "held PR is no longer open; cannot request a fresh decision"
3598 );
3599 disarm(forge, state, &repo, pr.number)
3602 .await
3603 .map_err(anyhow::Error::msg)?;
3604 let previous = state.merge_decision.take();
3605 let binding = state.land_approval.take();
3606 state.config.graph.land_approval = true;
3607 let subject = merge_subject(
3608 crate::graph::landing_title(state, &seen.title),
3609 &crate::graph::landing_subject_source(state),
3610 );
3611 let contested = contested_to_ask(state);
3612 if let Err(error) =
3613 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3614 {
3615 state.merge_decision = previous;
3616 state.land_approval = binding;
3617 return Err(error);
3618 }
3619 state.end_pass_parked(crate::run::ParkCause::Question);
3620 state.parked = true;
3621 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()));
3622 state.save()?;
3623 return Ok(pr);
3624 }
3625 if owner_stop(state, &mut pr, forge).await? {
3626 return Ok(pr);
3627 }
3628
3629 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3630 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3635 let why = format!(
3636 "a previous pass may have armed auto-merge and it could not be disabled \
3637 on resume: {e}"
3638 );
3639 stop(state, &repo, &pr, &why).await?;
3640 return Ok(pr);
3641 }
3642 }
3643
3644 if pr.state == PrLifecycle::Open
3649 && !seen.head.is_empty()
3650 && state
3651 .land_armed_head
3652 .as_deref()
3653 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3654 {
3655 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3656 let why = format!(
3657 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3658 );
3659 stop(state, &repo, &pr, &why).await?;
3660 return Ok(pr);
3661 }
3662 }
3663
3664 if pr.state == PrLifecycle::Open {
3665 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3666 if waited >= WAIT_CEILING {
3667 let want = awaiting_head.as_deref().unwrap_or_default();
3668 let why = format!(
3669 "the pull request's checks were still not about one readable head after \
3670 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3671 someone may have pushed over it",
3672 WAIT_CEILING.as_secs() / 60,
3673 if want.is_empty() { "any" } else { want },
3674 if seen.head.is_empty() {
3675 "nothing readable"
3676 } else {
3677 &seen.head
3678 },
3679 if seen.rollup_head.is_empty() {
3680 "nothing readable"
3681 } else {
3682 &seen.rollup_head
3683 },
3684 );
3685 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3686 return Ok(pr);
3687 }
3688 waited += POLL;
3689 forge.poll().await;
3690 continue;
3691 }
3692 if awaiting_head.take().is_some() {
3695 waited = Duration::ZERO;
3698 }
3699 }
3700
3701 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3706 let failing = rerunnable(&seen.contexts);
3707 let now = forge.now();
3708 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3709 RerunStep::Rerun(runs) => {
3710 let rec = state.land_reruns.get_or_insert_with(Default::default);
3711 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3712 *rec = LandReruns {
3713 head: seen.head.clone(),
3714 runs: BTreeMap::new(),
3715 };
3716 }
3717 for run in &runs {
3718 rec.runs.insert(run.clone(), now);
3719 }
3720 state.save()?;
3724 let mut any = false;
3725 for run in &runs {
3726 let names = failing
3727 .iter()
3728 .filter(|(_, r)| r.as_deref() == Some(run))
3729 .map(|(l, _)| l.as_str())
3730 .collect::<Vec<_>>()
3731 .join(", ");
3732 match forge.rerun(&repo, run).await {
3733 Ok(()) => {
3734 any = true;
3735 state.event(
3736 "land",
3737 format!("reran failed check(s) {names} (run {run})"),
3738 );
3739 }
3740 Err(e) => state.event(
3742 "land",
3743 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3744 ),
3745 }
3746 }
3747 state.save()?;
3748 if any {
3749 waited = Duration::ZERO;
3750 forge.poll().await;
3751 continue;
3752 }
3753 }
3754 RerunStep::Grace => {
3755 if waited >= WAIT_CEILING {
3756 let why = format!(
3757 "checks were still failing after a rerun and {} minutes",
3758 WAIT_CEILING.as_secs() / 60
3759 );
3760 stop(state, &repo, &pr, &why).await?;
3761 return Ok(pr);
3762 }
3763 waited += POLL;
3764 forge.poll().await;
3765 continue;
3766 }
3767 RerunStep::Fix => {}
3768 }
3769 }
3770
3771 let step = decide(&pr, round, budget, waited);
3772 let armed_here = state
3777 .land_armed_head
3778 .as_deref()
3779 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3780 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3781 if waited >= WAIT_CEILING {
3782 let required = if seen.base.is_empty() {
3783 None
3784 } else {
3785 forge.required_contexts(&repo, &seen.base).await
3786 };
3787 let why = format!(
3788 "auto-merge was armed on {} but the pull request did not merge within {} \
3789 minutes ({})",
3790 seen.head,
3791 WAIT_CEILING.as_secs() / 60,
3792 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3793 );
3794 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3795 return Ok(pr);
3796 }
3797 waited += POLL;
3798 forge.poll().await;
3799 continue;
3800 }
3801 if armed_here && !matches!(step, Step::Done { .. }) {
3802 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3805 let why = format!("auto-merge could not be disabled: {e}");
3806 stop(state, &repo, &pr, &why).await?;
3807 return Ok(pr);
3808 }
3809 }
3810 match step {
3811 Step::Wait => {
3812 if waited >= WAIT_CEILING {
3813 let why = format!(
3814 "checks were still running after {} minutes",
3815 WAIT_CEILING.as_secs() / 60
3816 );
3817 stop(state, &repo, &pr, &why).await?;
3818 return Ok(pr);
3819 }
3820 waited += POLL;
3821 forge.poll().await;
3822 }
3823 Step::Done { merged } => {
3824 if let Some(armed) = state.land_armed_head.take() {
3829 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3830 let msg = format!(
3831 "{} merged on {} but the owner approved {armed}; review what landed",
3832 pr.url, seen.head
3833 );
3834 tracing::warn!("{msg}");
3835 state.event("land", msg);
3836 crate::notices::raise_with(
3840 crate::notices::Notice::warn(
3841 &format!("merged-unapproved-head:{}", state.id),
3842 "A pull request merged on a commit the owner did not approve; \
3843 review what landed",
3844 )
3845 .link(crate::notices::Link::Run {
3846 id: state.id.clone(),
3847 }),
3848 &state.config.notify,
3849 );
3850 }
3851 }
3852 state.status = if merged {
3853 RunStatus::Merged
3854 } else {
3855 RunStatus::Ready
3856 };
3857 let detail = if merged {
3858 format!("{} was merged", pr.url)
3859 } else {
3860 format!("{} was closed without merging", pr.url)
3861 };
3862 state.merge = Some(MergeOutcome {
3863 mode: MergeMode::Pr,
3864 ok: merged,
3865 detail: detail.clone(),
3866 empty: false,
3867 });
3868 state.event("land", detail);
3869 state.save()?;
3870 write_pr_state_through(state, pr.state);
3871 return Ok(pr);
3872 }
3873 Step::Merge => {
3874 let subject = merge_subject(
3875 crate::graph::landing_title(state, &seen.title),
3876 &crate::graph::landing_subject_source(state),
3877 );
3878 let contested = contested_to_ask(state);
3885 if state.config.graph.land_approval
3886 || contested.is_some()
3887 || repair_available(state)
3888 || !state.merge_repairs.is_empty()
3889 {
3890 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3891 .await?
3892 {
3893 ApprovalGate::Approved => {}
3894 ApprovalGate::Repair => {
3895 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3898 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3899 state.save()?;
3900 }
3901 return Ok(pr);
3902 }
3903 ApprovalGate::Held => {
3904 if owner_stop(state, &mut pr, forge).await? {
3907 return Ok(pr);
3908 }
3909 stop(
3910 state,
3911 &repo,
3912 &pr,
3913 "the owner did not approve the merge (held or unanswered)",
3914 )
3915 .await?;
3916 return Ok(pr);
3917 }
3918 ApprovalGate::Pending => {
3926 state.end_pass_parked(crate::run::ParkCause::Question);
3927 state.parked = true;
3928 state.event(
3929 "land",
3930 "parked awaiting merge approval - resumes once answered",
3931 );
3932 state.save()?;
3933 return Ok(pr);
3934 }
3935 }
3936 }
3937 let observed_head = seen.head.clone();
3948 {
3951 let fresh = forge.view(&repo, pr_url).await.ok();
3952 if !direct_merge_is_safe(
3953 fresh.as_ref(),
3954 &observed_head,
3955 &shown,
3956 round,
3957 budget,
3958 waited,
3959 ) {
3960 if waited >= WAIT_CEILING {
3961 let why = "the pull request did not settle on the approved head \
3962 before it could be merged";
3963 stop(state, &repo, &pr, why).await?;
3964 return Ok(pr);
3965 }
3966 state.event(
3967 "land",
3968 "the pull request changed before merging; looking again",
3969 );
3970 waited += POLL;
3971 forge.poll().await;
3972 continue;
3973 }
3974 }
3975 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3976 let out = {
3977 let merge_lock = repo_merge_lock(&repo);
3978 let _merge_slot = merge_lock.lock().await;
3979 crate::pr_head::ensure_owner(state)?;
3980 if let Some(task_id) = state
3981 .origin
3982 .as_ref()
3983 .and_then(|origin| origin.task.as_ref())
3984 {
3985 let task = crate::queue::Queue::open()
3986 .get(task_id)
3987 .context("cannot verify the task owner immediately before merge")?;
3988 if task.runs.last() != Some(&state.id)
3989 || task.operator_held()
3990 || task.status == crate::queue::TaskStatus::Done
3991 {
3992 bail!(
3993 "task ownership or an operator hold superseded this merge approval"
3994 );
3995 }
3996 }
3997 if let Some(binding) = &state.land_approval {
3998 let current = ask::Questions::open().get(&binding.question)?;
3999 if merge_consent_superseded(state, ¤t)?
4000 || current.run != state.id
4001 || !binding.head.eq_ignore_ascii_case(&observed_head)
4002 || approval(current.resolution().as_deref()) != Approval::Merge
4003 || current.merge_target.as_ref().is_some_and(|t| {
4004 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4005 })
4006 {
4007 bail!(
4008 "merge decision is stale or no longer authorizes the reviewed head"
4009 );
4010 }
4011 }
4012 forge.merge(&repo, &argv).await?
4013 };
4014 if out.0 {
4015 let confirmed = forge
4021 .view(&repo, pr_url)
4022 .await
4023 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
4024 if !confirmed {
4025 if waited >= WAIT_CEILING {
4026 let why = "the merge request succeeded but the pull request \
4027 could not be confirmed merged after waiting";
4028 stop(state, &repo, &pr, why).await?;
4029 return Ok(pr);
4030 }
4031 state.event(
4032 "land",
4033 "merge accepted but the pull request is not confirmed merged yet; waiting",
4034 );
4035 state.save()?;
4036 waited += POLL;
4037 forge.poll().await;
4038 continue;
4039 }
4040 pr.state = PrLifecycle::Merged;
4041 state.status = RunStatus::Merged;
4042 state.merge = Some(MergeOutcome {
4043 mode: MergeMode::Pr,
4044 ok: true,
4045 detail: format!("gh {}", argv.join(" ")),
4046 empty: false,
4047 });
4048 if let Some(pr_record) = state.pr.as_mut() {
4053 pr_record.state = pr.state.as_str().to_owned();
4054 }
4055 state.event("land", format!("merged {} as `{subject}`", pr.url));
4056 announce_red_merge(state, &pr).await;
4057 state.save()?;
4058 write_pr_state_through(state, pr.state);
4059 return Ok(pr);
4060 }
4061 let after_seen = forge.view(&repo, pr_url).await.ok();
4062 let after = after_seen.as_ref().map(|s| s.pr.state);
4063 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4064 pr.state = PrLifecycle::Merged;
4065 state.status = RunStatus::Merged;
4066 state.merge = Some(outcome);
4067 if let Some(pr_record) = state.pr.as_mut() {
4068 pr_record.state = pr.state.as_str().to_owned();
4069 }
4070 state.event("land", format!("merged {} as `{subject}`", pr.url));
4071 announce_red_merge(state, &pr).await;
4072 state.save()?;
4073 write_pr_state_through(state, pr.state);
4074 return Ok(pr);
4075 }
4076 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4077 match verdict {
4078 Refused::Final => {
4079 let merge_state = after_seen
4080 .as_ref()
4081 .map(|s| s.merge_state.as_str())
4082 .filter(|m| !m.is_empty())
4083 .unwrap_or("unknown");
4084 let why = format!(
4088 "the merge was refused: {} (merge state: {merge_state})",
4089 out.1
4090 );
4091 stop(state, &repo, &pr, &why).await?;
4092 return Ok(pr);
4093 }
4094 verdict => {
4095 if waited >= WAIT_CEILING {
4100 let why = format!(
4101 "the merge was still refused after {} minutes: {}",
4102 WAIT_CEILING.as_secs() / 60,
4103 out.1
4104 );
4105 stop(state, &repo, &pr, &why).await?;
4106 return Ok(pr);
4107 }
4108 if verdict == Refused::Recheck {
4109 rechecked = true;
4110 }
4111 state.event(
4112 "land",
4113 "merge refused while the branch policy is not satisfied yet; waiting",
4114 );
4115 state.save()?;
4116 waited += POLL;
4117 forge.poll().await;
4118 }
4119 }
4120 }
4121 Step::Rebase => {
4122 if rebases >= budget {
4128 let why = format!(
4129 "the base moved under this branch {budget} time(s) and it still does \
4130 not merge; rebasing again would only race it"
4131 );
4132 stop(state, &repo, &pr, &why).await?;
4133 return Ok(pr);
4134 }
4135 rebases += 1;
4136 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4137 stop(
4138 state,
4139 &repo,
4140 &pr,
4141 "the pull request conflicts and this run has no winning branch to rebase",
4142 )
4143 .await?;
4144 return Ok(pr);
4145 };
4146 let base = state.base_branch.clone();
4147 state.event(
4148 "land",
4149 format!("{} no longer merges; rebasing onto {base}", pr.url),
4150 );
4151 state.save()?;
4152
4153 let remote = state.config.merge.remote.clone();
4157 git::fetch(&repo, &remote, &base).await.ok();
4158 if owner_stop(state, &mut pr, forge).await? {
4159 return Ok(pr);
4160 }
4161 let scratch = state.dir().join("rebase");
4162 let onto = format!("{remote}/{base}");
4163 let rebased =
4164 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4165 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4166 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4167 Err(e) => Err(e),
4168 };
4169 if owner_stop(state, &mut pr, forge).await? {
4170 return Ok(pr);
4171 }
4172 match rebased {
4173 Ok(None) => {
4174 let pushed = {
4175 let merge_lock = repo_merge_lock(&repo);
4176 let _merge_slot = merge_lock.lock().await;
4177 if let Some(head) = &state.pr_head {
4178 git::push_as(
4179 &repo,
4180 &state.config.merge.remote,
4181 &branch,
4182 head,
4183 state
4184 .pr_pushed_head
4185 .as_deref()
4186 .context("missing public head lease")?,
4187 )
4188 .await?
4189 } else {
4190 git::push_pinned(
4191 &repo,
4192 &state.config.merge.remote,
4193 &branch,
4194 &seen.head,
4195 )
4196 .await?
4197 }
4198 };
4199 if !pushed.ok() {
4200 let why = format!(
4201 "rebased {branch} but could not push it: {}",
4202 pushed.stderr.trim()
4203 );
4204 stop(state, &repo, &pr, &why).await?;
4205 return Ok(pr);
4206 }
4207 let head =
4210 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4211 Ok(head) => head,
4212 Err(e) => {
4213 let why = format!(
4214 "rebased and pushed {branch} but could not read the pushed \
4215 commit: {e:#}"
4216 );
4217 stop(state, &repo, &pr, &why).await?;
4218 return Ok(pr);
4219 }
4220 };
4221 crate::graph::refresh_reviewed_commits(state, &branch).await;
4222 state.pr_pushed_head = Some(head.clone());
4223 awaiting_head = Some(head);
4224 rechecked = false;
4225 state.event("land", format!("rebased {branch} onto {base}"));
4226 state.save()?;
4227 waited = Duration::ZERO;
4230 tokio::time::sleep(POLL).await;
4231 }
4232 Ok(Some(conflict)) => {
4235 let why = format!(
4236 "{} conflicts with {base} and the rebase did not apply: {}",
4237 pr.url,
4238 conflict.chars().take(600).collect::<String>()
4239 );
4240 stop(state, &repo, &pr, &why).await?;
4241 return Ok(pr);
4242 }
4243 Err(e) => {
4244 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4245 stop(state, &repo, &pr, &why).await?;
4246 return Ok(pr);
4247 }
4248 }
4249 }
4250 Step::GiveUp { reason } => {
4251 stop(state, &repo, &pr, &reason).await?;
4252 return Ok(pr);
4253 }
4254 Step::Fix { reason } => {
4255 round += 1;
4256 waited = Duration::ZERO;
4257 for c in &pr.review_comments {
4258 shown.insert(c.body.clone());
4259 }
4260 state.event("land", format!("round {round}: {reason}"));
4261 state.save()?;
4262
4263 let logs = failing_logs(&repo, &seen.failing_urls).await;
4264 let was_red = pr.checks == Checks::Red;
4265 if owner_stop(state, &mut pr, forge).await? {
4266 return Ok(pr);
4267 }
4268 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4269 if owner_stop(state, &mut pr, forge).await? {
4270 return Ok(pr);
4271 }
4272 match fixed {
4273 Fixed::Committed { head } => {
4274 state.pr_pushed_head = Some(head.clone());
4277 awaiting_head = Some(head);
4278 rechecked = false;
4279 waited = Duration::ZERO;
4280 forge.poll().await;
4281 }
4282 Fixed::Declined if was_red => {
4283 let why = format!(
4284 "the fixer produced no commit while {} check(s) were failing \
4285 ({}); stopping instead of looping on an unchanged tree",
4286 pr.failing.len(),
4287 pr.failing.join(", ")
4288 );
4289 stop(state, &repo, &pr, &why).await?;
4290 return Ok(pr);
4291 }
4292 Fixed::Declined => state.event(
4297 "land",
4298 format!("round {round}: fixer declined the comments, nothing committed"),
4299 ),
4300 Fixed::Failed(why) => {
4301 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4302 return Ok(pr);
4303 }
4304 }
4305 state.save()?;
4306 }
4307 }
4308 }
4309}
4310
4311#[derive(Clone)]
4315struct Seen {
4316 pr: PrState,
4317 title: String,
4318 failing_urls: Vec<(String, String)>,
4319 head: String,
4321 rollup_head: String,
4324 merge_state: String,
4327 contexts: Vec<CheckInfo>,
4329 base: String,
4331}
4332
4333#[derive(Clone)]
4335struct CheckInfo {
4336 label: String,
4337 verdict: Verdict,
4338 required: Option<bool>,
4339 run: Option<String>,
4341}
4342
4343#[derive(Debug, Clone, PartialEq, Eq)]
4345enum RerunStep {
4346 Rerun(Vec<String>),
4348 Grace,
4350 Fix,
4352}
4353
4354fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4358 contexts
4359 .iter()
4360 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4361 .filter(|c| {
4363 c.run.as_ref().is_none_or(|r| {
4364 !contexts.iter().any(|o| {
4365 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4366 && o.run.as_ref() == Some(r)
4367 })
4368 })
4369 })
4370 .map(|c| (c.label.clone(), c.run.clone()))
4371 .collect()
4372}
4373
4374fn rerun_step(
4376 failing: &[(String, Option<String>)],
4377 record: Option<&LandReruns>,
4378 head: &str,
4379 now: i64,
4380) -> RerunStep {
4381 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4384 return RerunStep::Fix;
4385 }
4386 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4388 let mut fresh: Vec<String> = Vec::new();
4389 let mut recent = false;
4390 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4391 match record.and_then(|r| r.runs.get(run)) {
4392 None => {
4393 if !fresh.iter().any(|f| f == run) {
4394 fresh.push(run.to_owned());
4395 }
4396 }
4397 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4398 Some(_) => {}
4399 }
4400 }
4401 if !fresh.is_empty() {
4402 RerunStep::Rerun(fresh)
4403 } else if recent {
4404 RerunStep::Grace
4405 } else {
4406 RerunStep::Fix
4407 }
4408}
4409
4410async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4414 let view = gh(
4415 repo,
4416 &[
4417 "pr".to_owned(),
4418 "view".to_owned(),
4419 pr_url.to_owned(),
4420 "--json".to_owned(),
4421 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4422 .to_owned(),
4423 ],
4424 )
4425 .await?;
4426 if !view.0 {
4427 bail!("gh pr view {pr_url}: {}", view.1);
4428 }
4429 let number = parse_pr(&view.1)?.number;
4430 let node = last_commit_node(repo, number).await;
4431 let mut seen = seen_from(&view.1, node.as_deref())?;
4432
4433 let inline = gh(
4434 repo,
4435 &[
4436 "api".to_owned(),
4437 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4438 ],
4439 )
4440 .await?;
4441 if inline.0 {
4442 match parse_inline_comments(&inline.1) {
4443 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4444 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4447 }
4448 } else {
4449 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4450 }
4451 Ok(seen)
4452}
4453
4454fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4465 let mut pr = parse_pr(view_json)?;
4466 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4467
4468 let mut rollup_head = String::new();
4469 let mut failing_urls = Vec::new();
4470 let mut contexts = Vec::new();
4471 let mut checks = Checks::Unknown;
4472 let mut failing = Vec::new();
4473 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4474 (checks, failing) = rollup_verdict(&rollup);
4475 failing_urls = rollup
4476 .iter()
4477 .filter(|c| c.verdict() == Verdict::Fail)
4478 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4479 .collect();
4480 contexts = rollup
4481 .iter()
4482 .map(|c| CheckInfo {
4483 label: c.label(),
4484 verdict: c.verdict(),
4485 required: c.is_required,
4486 run: c.url().and_then(run_of),
4487 })
4488 .collect();
4489 rollup_head = oid;
4490 }
4491 pr.checks = checks;
4492 pr.failing = failing;
4493
4494 Ok(Seen {
4495 pr,
4496 title: raw.title,
4497 failing_urls,
4498 head: raw.head_ref_oid,
4499 rollup_head,
4500 merge_state: raw.merge_state_status,
4501 contexts,
4502 base: raw.base_ref_name,
4503 })
4504}
4505
4506fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4509 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4510 if v.get("errors").is_some_and(|e| !e.is_null()) {
4511 return None;
4512 }
4513 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4514 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4515 let contexts = commit.pointer("/statusCheckRollup/contexts");
4516 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4517 return Some((oid.to_owned(), Vec::new()));
4519 };
4520 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4521 return None;
4522 }
4523 let nodes = contexts.get("nodes")?.as_array()?;
4524 let rollup = nodes
4525 .iter()
4526 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4527 .collect::<Result<Vec<_>, _>>()
4528 .ok()?;
4529 Some((oid.to_owned(), rollup))
4530}
4531
4532async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4535 let out = gh(
4536 repo,
4537 &[
4538 "api".to_owned(),
4539 "graphql".to_owned(),
4540 "-F".to_owned(),
4541 "owner={owner}".to_owned(),
4542 "-F".to_owned(),
4543 "repo={repo}".to_owned(),
4544 "-F".to_owned(),
4545 format!("number={number}"),
4546 "-f".to_owned(),
4547 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4548 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4549 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4550 ... on CheckRun{name status conclusion detailsUrl \
4551 isRequired(pullRequestNumber:$number)} \
4552 ... on StatusContext{context state targetUrl \
4553 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4554 .to_owned(),
4555 ],
4556 )
4557 .await
4558 .ok()?;
4559 out.0.then_some(out.1)
4560}
4561
4562#[doc(hidden)]
4564#[derive(Debug, PartialEq)]
4565pub enum Fixed {
4566 Committed {
4568 head: String,
4570 },
4571 Declined,
4573 Failed(String),
4575}
4576
4577#[doc(hidden)]
4583pub async fn fix_round(
4584 state: &mut RunState,
4585 pr: &PrState,
4586 round: usize,
4587 budget: usize,
4588 reason: &str,
4589 logs: &str,
4590) -> Result<Fixed> {
4591 let winner = state
4592 .winner()
4593 .cloned()
4594 .context("landing needs a winning candidate; none is recorded on this run")?;
4595 let roles = state
4596 .config
4597 .resolve_roles()
4598 .context("resolve the roster for the fix round")?;
4599 let attempts = crate::fixer::attempts(state, &roles, &winner);
4603 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4604
4605 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4606 let mut artifacts = agent::artifacts_dir(&state.dir());
4607 if let Some(request) = state
4608 .merge_repairs
4609 .last()
4610 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4611 {
4612 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4613 }
4614 let prompt = if state.config.cache_dir().is_some() {
4615 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4616 } else {
4617 prompt
4618 };
4619 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4623 let mut last = None;
4627 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4628 if owner_requested_stop(state)? {
4629 return Ok(Fixed::Declined);
4630 }
4631 let mut seat = seat_of(state, &seat_key, &spec.id);
4632 let stem = if i == 0 {
4633 format!("land-{round}")
4634 } else {
4635 format!("land-{round}-{}", spec.id)
4636 };
4637 let out = agent::invoke(
4638 &spec,
4639 &mut seat,
4640 &Invocation {
4641 cwd: &winner.worktree,
4642 prompt: &prompt,
4643 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4644 allow_write: true,
4645 unsandboxed: false,
4646 sessions: state.config.graph.sessions,
4647 artifacts: &artifacts,
4648 stem: &stem,
4649 run: &state.id,
4650 node: "land",
4651 cache_dir: state.config.cache_dir().as_deref(),
4652 attachments: &[],
4653 writable: &[],
4654 },
4655 )
4656 .await;
4657 state.seats.insert(seat.key.clone(), seat);
4658 if let Some(next) = ids.get(i + 1)
4659 && agent::chain_advances(&out)
4660 {
4661 let (class, why) = crate::fixer::failure_of(&out);
4662 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4663 continue;
4664 }
4665 last = Some(out);
4666 break;
4667 }
4668 let out = last.expect("the fixer chain always has an entry");
4669
4670 match out {
4671 Ok(o) if o.quota_exhausted() => {
4672 return Ok(Fixed::Failed(
4673 "rate limited (quota); the fixer could not run".to_owned(),
4674 ));
4675 }
4676 Ok(o) if !o.usable() => {
4677 return Ok(Fixed::Failed(format!(
4678 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4679 o.exit_code, o.timed_out
4680 )));
4681 }
4682 Ok(_) => {}
4683 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4684 }
4685
4686 if owner_requested_stop(state)? {
4687 return Ok(Fixed::Declined);
4688 }
4689
4690 match git::rescue_commit(
4693 &winner.worktree,
4694 &format!("magi: land round {round} fixes (uncommitted work)"),
4695 )
4696 .await
4697 {
4698 Ok(r) => state.note_withheld("land", &r.withheld),
4699 Err(e)
4700 if state
4701 .merge_repairs
4702 .last()
4703 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4704 {
4705 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4706 }
4707 Err(_) => {}
4708 }
4709 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4710 if after == before {
4711 return Ok(Fixed::Declined);
4712 }
4713
4714 if owner_requested_stop(state)? {
4715 return Ok(Fixed::Declined);
4716 }
4717 crate::pr_head::ensure_owner(state)?;
4718 let remote = state.config.merge.remote.clone();
4719 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4720 let push = if state.pr_head.is_some() {
4721 let expected = state
4722 .pr_pushed_head
4723 .as_deref()
4724 .context("missing public head lease")?;
4725 git::push_as(
4726 &winner.worktree,
4727 &remote,
4728 &winner.branch,
4729 &public_head,
4730 expected,
4731 )
4732 .await?
4733 } else {
4734 git::push(&winner.worktree, &remote, &winner.branch).await?
4735 };
4736 if !push.ok() {
4737 return Ok(Fixed::Failed(format!(
4738 "pushing {} to {remote} failed: {}",
4739 public_head, push.stderr
4740 )));
4741 }
4742 state.event(
4743 "land",
4744 format!("round {round}: pushed a fix to {public_head}"),
4745 );
4746 state.pr_pushed_head = Some(after.clone());
4747 state.save()?;
4748 Ok(Fixed::Committed { head: after })
4749}
4750
4751pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4753 crate::fixer::seat_for(state, key, agent)
4754}
4755
4756pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4759 let seen = observe(&state.repo, url).await?;
4760 let winner = state.winner().context("no winning branch to repair")?;
4761 if seen.pr.state != PrLifecycle::Open || seen.head != head {
4762 anyhow::bail!(
4763 "PR is closed or its head changed; review the current head before requesting repair again"
4764 );
4765 }
4766 if git::rev_parse(&winner.worktree, "HEAD").await? != head
4767 || !git::is_clean(&winner.worktree).await?
4768 {
4769 anyhow::bail!(
4770 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4771 );
4772 }
4773 let view = gh(
4776 &state.repo,
4777 &[
4778 "pr".into(),
4779 "view".into(),
4780 url.into(),
4781 "--json".into(),
4782 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4783 ],
4784 )
4785 .await?;
4786 if !view.0 {
4787 bail!("cannot check auto-merge: {}", view.1);
4788 }
4789 let live: serde_json::Value = serde_json::from_str(&view.1)?;
4790 let public = state.pr_branch().unwrap_or(&winner.branch);
4791 if live["state"] != "OPEN"
4792 || live["headRefOid"] != head
4793 || live["headRefName"] != public
4794 || live["baseRefName"] != state.base_branch
4795 || live["isCrossRepository"] != false
4796 {
4797 bail!("PR changed during repair preflight; review its current head explicitly");
4798 }
4799 let auto_merge = live
4800 .get("autoMergeRequest")
4801 .context("cannot read the PR auto-merge reservation; repair held")?;
4802 if !auto_merge.is_null() {
4803 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4804 if !out.0 {
4805 bail!("cannot disable auto-merge; repair held: {}", out.1);
4806 }
4807 }
4808 Ok(seen.pr)
4809}
4810
4811fn fix_prompt(
4813 state: &RunState,
4814 pr: &PrState,
4815 round: usize,
4816 budget: usize,
4817 reason: &str,
4818 logs: &str,
4819) -> String {
4820 let mut s = format!(
4821 "Your patch is open as a pull request and it is not landing. Land round \
4822 {round} of {budget}.\n\n\
4823 Pull request: {}\n\n\
4824 What is holding it: {reason}\n\n\
4825 # The task\n\n{}\n",
4826 pr.url, state.instruction
4827 );
4828
4829 if pr.failing.is_empty() {
4830 s.push_str("\n# Failing checks\n\n(none)\n");
4831 } else {
4832 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4833 if logs.trim().is_empty() {
4834 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4835 } else {
4836 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4837 }
4838 }
4839
4840 if pr.review_comments.is_empty() {
4841 s.push_str("\n# Review comments\n\n(none)\n");
4842 } else {
4843 s.push_str("\n# Review comments\n");
4844 for c in &pr.review_comments {
4845 let where_ = match (&c.path, c.line) {
4846 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4847 (Some(p), None) => format!(" ({p})"),
4848 _ => String::new(),
4849 };
4850 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4851 }
4852 }
4853
4854 s.push_str(
4855 "\n# Rules\n\n\
4856 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4857 failing test; do not silence a lint with an allow attribute; do not \
4858 stretch a timeout to hide a race. If the check is right, the code is \
4859 wrong.\n\
4860 2. Change nothing the checks and the comments did not raise. A \
4861 drive-by refactor turns a one-line fix into a pull request that \
4862 needs reviewing again.\n\
4863 3. If a comment is wrong, say so with a checkable argument and change \
4864 nothing for it. A declined comment with a reason is a correct \
4865 outcome; a change made to appease a reviewer is not.\n\
4866 4. Commit in this worktree. magi pushes to the pull request's branch \
4867 for you; do not push, merge, or close anything yourself.\n\
4868 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4869 # Output\n\n\
4870 Say what you changed and why, and what you declined and why.",
4871 );
4872
4873 let language = &state.config.graph.language;
4874 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4875 let _ = write!(s, "\n\nWrite all prose in {language}.");
4876 }
4877 s.push_str(&crate::prompt::github_rule(
4879 language,
4880 &state.config.graph.github_language,
4881 ));
4882 if let Some(overlay) = state.config.prompts.overlay("fix") {
4883 let _ = write!(s, "\n\n{overlay}");
4884 }
4885 s
4886}
4887
4888async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4891 let mut out = String::new();
4892 for (name, url) in failing.iter().take(MAX_LOGS) {
4893 let args = match (job_of(url), run_of(url)) {
4894 (Some(job), _) => vec![
4895 "run".to_owned(),
4896 "view".to_owned(),
4897 "--log-failed".to_owned(),
4898 "--job".to_owned(),
4899 job,
4900 ],
4901 (None, Some(run)) => vec![
4902 "run".to_owned(),
4903 "view".to_owned(),
4904 run,
4905 "--log-failed".to_owned(),
4906 ],
4907 (None, None) => continue,
4909 };
4910 let (ok, body) = match gh(repo, &args).await {
4911 Ok(v) => v,
4912 Err(e) => (false, format!("{e:#}")),
4913 };
4914 if !ok && body.trim().is_empty() {
4915 continue;
4916 }
4917 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4918 }
4919 out
4920}
4921
4922fn job_of(details_url: &str) -> Option<String> {
4925 let after = details_url.split("/job/").nth(1)?;
4926 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4927 (!id.is_empty()).then_some(id)
4928}
4929
4930pub(crate) fn run_of(details_url: &str) -> Option<String> {
4932 let after = details_url.split("/actions/runs/").nth(1)?;
4933 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4934 (!id.is_empty()).then_some(id)
4935}
4936
4937fn stop_comment(run_id: &str, why: &str) -> String {
4941 format!(
4942 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4943 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4944 )
4945}
4946
4947async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4952 let body = stop_comment(&state.id, why);
4953 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4954 if !body.contains(MARKER) {
4955 body = format!("{MARKER}\n{body}");
4956 }
4957 let posted = gh(
4958 repo,
4959 &[
4960 "pr".to_owned(),
4961 "comment".to_owned(),
4962 pr.number.to_string(),
4963 "--body".to_owned(),
4964 body,
4965 ],
4966 )
4967 .await;
4968 match posted {
4969 Ok((true, _)) => {}
4970 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4971 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4972 }
4973 state.status = RunStatus::Blocked;
4974 state.merge = Some(MergeOutcome {
4975 mode: MergeMode::Pr,
4976 ok: false,
4977 detail: why.to_owned(),
4978 empty: false,
4979 });
4980 state.event("land", format!("stopped: {why}"));
4981 state.save()?;
4982 Ok(())
4983}
4984
4985pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5001 let out = tokio::process::Command::new("gh")
5002 .args(args)
5003 .current_dir(cwd)
5004 .env_remove("GH_REPO")
5005 .quiet()
5006 .stdin(std::process::Stdio::null())
5007 .output()
5008 .await
5009 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5010 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5011 let err = String::from_utf8_lossy(&out.stderr);
5012 if body.trim().is_empty() {
5013 body = err.into_owned();
5014 } else if !err.trim().is_empty() {
5015 body.push_str(&err);
5016 }
5017 Ok((out.status.success(), body.trim().to_owned()))
5018}
5019
5020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5022pub(crate) enum Verdict {
5023 Pass,
5024 Fail,
5025 Pending,
5026 Unknown,
5027}
5028
5029#[derive(Debug, Deserialize)]
5030#[serde(rename_all = "camelCase")]
5031struct GhPr {
5032 #[serde(default)]
5033 url: String,
5034 #[serde(default)]
5035 number: u64,
5036 #[serde(default)]
5037 state: String,
5038 #[serde(default)]
5039 title: String,
5040 #[serde(default)]
5041 status_check_rollup: Vec<GhCheck>,
5042 #[serde(default)]
5049 merge_state_status: String,
5050 #[serde(default)]
5054 head_ref_oid: String,
5055 #[serde(default)]
5056 base_ref_name: String,
5057 #[serde(default)]
5058 reviews: Vec<GhReview>,
5059 #[serde(default)]
5060 comments: Vec<GhComment>,
5061}
5062
5063#[derive(Debug, Deserialize)]
5068#[serde(rename_all = "camelCase")]
5069struct GhCheck {
5070 #[serde(default)]
5071 name: Option<String>,
5072 #[serde(default)]
5073 context: Option<String>,
5074 #[serde(default)]
5075 status: Option<String>,
5076 #[serde(default)]
5077 conclusion: Option<String>,
5078 #[serde(default)]
5079 state: Option<String>,
5080 #[serde(default)]
5081 details_url: Option<String>,
5082 #[serde(default)]
5083 target_url: Option<String>,
5084 #[serde(default)]
5087 is_required: Option<bool>,
5088}
5089
5090impl GhCheck {
5091 fn label(&self) -> String {
5093 self.name
5094 .clone()
5095 .or_else(|| self.context.clone())
5096 .unwrap_or_else(|| "(unnamed check)".to_owned())
5097 }
5098
5099 fn url(&self) -> Option<&str> {
5101 self.details_url
5102 .as_deref()
5103 .or(self.target_url.as_deref())
5104 .filter(|u| !u.is_empty())
5105 }
5106
5107 fn verdict(&self) -> Verdict {
5115 if let Some(status) = self.status.as_deref() {
5116 if !status.eq_ignore_ascii_case("COMPLETED") {
5117 return Verdict::Pending;
5118 }
5119 }
5120 let outcome = self
5121 .conclusion
5122 .as_deref()
5123 .or(self.state.as_deref())
5124 .unwrap_or("");
5125 match outcome.to_ascii_uppercase().as_str() {
5126 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5127 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5128 | "ACTION_REQUIRED" => Verdict::Fail,
5129 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5130 Verdict::Pending
5131 }
5132 _ => Verdict::Unknown,
5133 }
5134 }
5135}
5136
5137#[derive(Debug, Deserialize)]
5138struct GhAuthor {
5139 #[serde(default)]
5140 login: String,
5141}
5142
5143#[derive(Debug, Deserialize)]
5144struct GhReview {
5145 #[serde(default)]
5146 author: GhAuthor,
5147 #[serde(default)]
5148 body: String,
5149}
5150
5151#[derive(Debug, Deserialize)]
5152struct GhComment {
5153 #[serde(default)]
5154 author: GhAuthor,
5155 #[serde(default)]
5156 body: String,
5157}
5158
5159#[derive(Debug, Deserialize)]
5160struct GhUser {
5161 #[serde(default)]
5162 login: String,
5163}
5164
5165#[derive(Debug, Deserialize)]
5166struct GhInline {
5167 #[serde(default)]
5168 user: GhUser,
5169 #[serde(default)]
5170 path: Option<String>,
5171 #[serde(default)]
5172 line: Option<u64>,
5173 #[serde(default)]
5174 body: String,
5175}
5176
5177impl Default for GhAuthor {
5178 fn default() -> Self {
5179 Self {
5180 login: "(unknown)".to_owned(),
5181 }
5182 }
5183}
5184
5185impl Default for GhUser {
5186 fn default() -> Self {
5187 Self {
5188 login: "(unknown)".to_owned(),
5189 }
5190 }
5191}
5192
5193#[cfg(test)]
5194pub(crate) mod tests {
5195 use super::*;
5196 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5197
5198 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5199 format!(
5200 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5201 )
5202 }
5203
5204 #[test]
5205 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5206 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5207 assert_eq!(
5208 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5209 Ok(())
5210 );
5211 for (json, why) in [
5212 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5213 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5214 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5215 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5216 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5217 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5218 ] {
5219 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5220 .unwrap_err()
5221 .why;
5222 assert!(err.contains(why), "{json}: {err}");
5223 }
5224 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5226 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5227 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5228 assert!(
5229 !closable(
5230 &head_json("x", "main", "OPEN", false),
5231 "magi/27b2/A",
5232 "main",
5233 &[]
5234 )
5235 .unwrap_err()
5236 .retry
5237 );
5238 assert!(is_forge_url("https://github.com/o/r.git"));
5239 assert!(is_forge_url("git@github.com:o/r.git"));
5240 assert!(!is_forge_url("/tmp/origin.git"));
5241 assert!(!is_forge_url("C:\\work\\origin.git"));
5242 assert!(!is_forge_url("file:///tmp/origin.git"));
5243 assert!(forge_unavailable(
5244 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5245 ));
5246 assert!(!forge_unavailable(
5247 "gh pr list failed: error connecting to api.github.com"
5248 ));
5249 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5250 assert!(
5252 closable(
5253 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5254 "b",
5255 "main",
5256 &["aaa".to_owned()]
5257 )
5258 .is_err()
5259 );
5260 }
5261
5262 #[test]
5263 fn the_close_comment_names_the_commit_on_the_base() {
5264 let e = crate::already::Evidence {
5265 proof: crate::already::Proof::PatchId,
5266 tip: "1234567890".to_owned(),
5267 commits: vec!["0e368de0000".to_owned()],
5268 };
5269 let c = superseded_comment("main", &e);
5270 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5271 }
5272
5273 const GREEN_OPEN: &str = r####"{
5275 "url": "https://github.com/yukimemi/magi/pull/10",
5276 "number": 10,
5277 "state": "OPEN",
5278 "mergeStateStatus": "CLEAN",
5279 "statusCheckRollup": [
5280 {
5281 "__typename": "CheckRun",
5282 "conclusion": "SKIPPED",
5283 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5284 "name": "review",
5285 "status": "COMPLETED",
5286 "workflowName": "claude-review"
5287 },
5288 {
5289 "__typename": "CheckRun",
5290 "conclusion": "SUCCESS",
5291 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5292 "name": "check (ubuntu-latest)",
5293 "status": "COMPLETED",
5294 "workflowName": "CI"
5295 },
5296 {
5297 "__typename": "CheckRun",
5298 "conclusion": "SUCCESS",
5299 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5300 "name": "rustfmt",
5301 "status": "COMPLETED",
5302 "workflowName": "CI"
5303 },
5304 {
5305 "__typename": "StatusContext",
5306 "context": "CodeRabbit",
5307 "state": "SUCCESS",
5308 "targetUrl": ""
5309 }
5310 ],
5311 "reviews": [],
5312 "comments": [
5313 {
5314 "author": {
5315 "login": "coderabbitai"
5316 },
5317 "authorAssociation": "NONE",
5318 "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"
5319 }
5320 ]
5321}"####;
5322
5323 const RED_OPEN: &str = r####"{
5325 "url": "https://github.com/yukimemi/magi/pull/9",
5326 "number": 9,
5327 "state": "OPEN",
5328 "mergeStateStatus": "UNSTABLE",
5329 "statusCheckRollup": [
5330 {
5331 "__typename": "CheckRun",
5332 "conclusion": "SUCCESS",
5333 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5334 "name": "check (ubuntu-latest)",
5335 "status": "COMPLETED",
5336 "workflowName": "CI"
5337 },
5338 {
5339 "__typename": "CheckRun",
5340 "conclusion": "SUCCESS",
5341 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5342 "name": "rustfmt",
5343 "status": "COMPLETED",
5344 "workflowName": "CI"
5345 },
5346 {
5347 "__typename": "CheckRun",
5348 "conclusion": "FAILURE",
5349 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5350 "name": "editorconfig",
5351 "status": "COMPLETED",
5352 "workflowName": "CI"
5353 },
5354 {
5355 "__typename": "StatusContext",
5356 "context": "CodeRabbit",
5357 "state": "SUCCESS",
5358 "targetUrl": ""
5359 }
5360 ],
5361 "reviews": [],
5362 "comments": [
5363 {
5364 "author": {
5365 "login": "coderabbitai"
5366 },
5367 "authorAssociation": "NONE",
5368 "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"
5369 }
5370 ]
5371}"####;
5372
5373 const PENDING_OPEN: &str = r####"{
5375 "url": "https://github.com/yukimemi/magi/pull/9",
5376 "number": 9,
5377 "state": "OPEN",
5378 "statusCheckRollup": [
5379 {
5380 "__typename": "CheckRun",
5381 "conclusion": "SUCCESS",
5382 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5383 "name": "check (ubuntu-latest)",
5384 "status": "COMPLETED",
5385 "workflowName": "CI"
5386 },
5387 {
5388 "__typename": "CheckRun",
5389 "conclusion": "SUCCESS",
5390 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5391 "name": "rustfmt",
5392 "status": "COMPLETED",
5393 "workflowName": "CI"
5394 },
5395 {
5396 "__typename": "CheckRun",
5397 "conclusion": null,
5398 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5399 "name": "editorconfig",
5400 "status": "IN_PROGRESS",
5401 "workflowName": "CI"
5402 },
5403 {
5404 "__typename": "StatusContext",
5405 "context": "CodeRabbit",
5406 "state": "SUCCESS",
5407 "targetUrl": ""
5408 }
5409 ],
5410 "reviews": [],
5411 "comments": []
5412}"####;
5413
5414 const MERGED: &str = r####"{
5416 "url": "https://github.com/yukimemi/magi/pull/16",
5417 "number": 16,
5418 "state": "MERGED",
5419 "statusCheckRollup": [
5420 {
5421 "__typename": "CheckRun",
5422 "conclusion": "SUCCESS",
5423 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5424 "name": "check (ubuntu-latest)",
5425 "status": "COMPLETED",
5426 "workflowName": "CI"
5427 },
5428 {
5429 "__typename": "CheckRun",
5430 "conclusion": "SUCCESS",
5431 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5432 "name": "review",
5433 "status": "COMPLETED",
5434 "workflowName": "claude-review"
5435 }
5436 ],
5437 "reviews": [],
5438 "comments": []
5439}"####;
5440
5441 const REVIEWED_OPEN: &str = r####"{
5443 "url": "https://github.com/yukimemi/magi/pull/12",
5444 "number": 12,
5445 "state": "OPEN",
5446 "statusCheckRollup": [
5447 {
5448 "__typename": "CheckRun",
5449 "conclusion": "SUCCESS",
5450 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5451 "name": "check (ubuntu-latest)",
5452 "status": "COMPLETED",
5453 "workflowName": "CI"
5454 },
5455 {
5456 "__typename": "CheckRun",
5457 "conclusion": "SUCCESS",
5458 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5459 "name": "review",
5460 "status": "COMPLETED",
5461 "workflowName": "claude-review"
5462 }
5463 ],
5464 "reviews": [
5465 {
5466 "author": {
5467 "login": "claude"
5468 },
5469 "state": "COMMENTED",
5470 "body": ""
5471 }
5472 ],
5473 "comments": [
5474 {
5475 "author": {
5476 "login": "coderabbitai"
5477 },
5478 "authorAssociation": "NONE",
5479 "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"
5480 },
5481 {
5482 "author": {
5483 "login": "claude"
5484 },
5485 "authorAssociation": "NONE",
5486 "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"
5487 }
5488 ]
5489}"####;
5490
5491 const INLINE: &str = r####"[
5493 {
5494 "user": {
5495 "login": "claude[bot]"
5496 },
5497 "path": "src/graph.rs",
5498 "line": 231,
5499 "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"
5500 }
5501]"####;
5502
5503 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5505<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5506
5507> [!IMPORTANT]
5508> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5509>
5510> This repository does not receive automatic reviews because it has fewer than 10 stars.
5511>
5512> <details>
5513> <summary>⚙️ Run configuration</summary>
5514>
5515> **Configuration used**: defaults
5516>
5517> **Review profile**: CHILL
5518>
5519> **Plan**: Team
5520>
5521> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5522>
5523> </details>
5524
5525<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5526
5527<!-- tips_start -->
5528
5529---
5530
5531Thanks 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.
5532
5533<details>
5534<summary>❤️ Share</summary>
5535
5536- [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"####;
5537
5538 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5540
5541---
5542### Reviewing PR #16
5543
5544- [x] Read AGENTS.md conventions
5545- [x] Review `src/daemon.rs` changes
5546- [x] Review `src/main.rs` changes (new `doctor` reporting)
5547- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5548- [x] Check test coverage for new behavior
5549- [x] Run verification commands (blocked — see note)
5550- [x] Post findings"####;
5551
5552 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5554
5555---
5556### Review: `magi review <branch>` — cheap-half-only graph
5557
5558Read 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.
5559
5560**Correctness**
5561
5562- 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"####;
5563
5564 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5565 PrState {
5566 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5567 number: 16,
5568 state: PrLifecycle::Open,
5569 checks,
5570 blocking: if matches!(checks, Checks::Red) {
5574 Blocking::Yes
5575 } else {
5576 Blocking::No
5577 },
5578 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5579 review_comments: (0..comments)
5580 .map(|i| ReviewComment {
5581 author: "coderabbitai".to_owned(),
5582 path: Some("src/graph.rs".to_owned()),
5583 line: Some(231),
5584 body: format!("finding {i}"),
5585 })
5586 .collect(),
5587 }
5588 }
5589
5590 #[test]
5591 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5592 use CiExpectation::{Absent, Expected};
5593 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5594 let p = pr(checks, &["x"], 0);
5595 for waited in [Duration::ZERO, CHECKS_GRACE] {
5596 assert_eq!(
5597 decide_with(&p, 0, 4, waited, Expected),
5598 decide(&p, 0, 4, waited)
5599 );
5600 }
5601 }
5602 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5604 let p = pr(checks, &["x"], 0);
5605 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5606 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5607 }
5608 let mut p = pr(Checks::Unknown, &[], 0);
5610 p.blocking = Blocking::Conflict;
5611 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5612 p.state = PrLifecycle::Merged;
5614 assert_eq!(
5615 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5616 Step::Done { merged: true }
5617 );
5618 }
5619
5620 #[test]
5621 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5622 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5623 assert_eq!(state.number, 10);
5624 assert_eq!(state.state, PrLifecycle::Open);
5625 assert_eq!(state.checks, Checks::Green);
5626 assert!(state.failing.is_empty());
5627 assert!(
5628 state.review_comments.is_empty(),
5629 "the only comment is CodeRabbit's trigger notice: {:?}",
5630 state.review_comments
5631 );
5632 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5633 }
5634
5635 #[test]
5636 fn a_failing_check_parses_as_red_and_is_named() {
5637 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5638 assert_eq!(state.checks, Checks::Red);
5639 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5640 let mut blocking = state.clone();
5647 blocking.blocking = Blocking::Yes;
5648 match decide(&blocking, 0, 4, Duration::ZERO) {
5649 Step::Fix { reason } => {
5650 assert!(reason.contains("editorconfig"), "reason: {reason}");
5651 assert!(reason.contains("failing"), "reason: {reason}");
5652 }
5653 other => panic!("expected a fix round, got {other:?}"),
5654 }
5655 }
5656
5657 #[test]
5658 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5659 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5660 assert_eq!(state.checks, Checks::Pending);
5661 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5662 }
5663
5664 #[test]
5665 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5666 let state = parse_pr(MERGED).expect("merged fixture parses");
5667 assert_eq!(state.state, PrLifecycle::Merged);
5668 assert_eq!(
5669 decide(&state, 0, 4, Duration::ZERO),
5670 Step::Done { merged: true }
5671 );
5672 }
5673
5674 #[test]
5675 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5676 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5677 assert_eq!(state.checks, Checks::Green);
5678 let authors: Vec<&str> = state
5679 .review_comments
5680 .iter()
5681 .map(|c| c.author.as_str())
5682 .collect();
5683 assert_eq!(
5684 authors,
5685 vec!["claude"],
5686 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5687 );
5688 match decide(&state, 0, 4, Duration::ZERO) {
5689 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5690 other => panic!("expected a fix round, got {other:?}"),
5691 }
5692 }
5693
5694 #[test]
5695 fn inline_review_comments_keep_their_file_and_line() {
5696 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5697 assert_eq!(comments.len(), 1);
5698 assert_eq!(comments[0].author, "claude[bot]");
5699 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5700 assert_eq!(comments[0].line, Some(231));
5701 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5702 }
5703
5704 #[test]
5705 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5706 assert!(
5707 is_noise(CODERABBIT_TRIGGER),
5708 "CodeRabbit's trigger notice declares itself not a review"
5709 );
5710 assert!(
5711 is_noise(CLAUDE_CHECKLIST),
5712 "a progress checklist asks for nothing"
5713 );
5714 assert!(
5715 !is_noise(CLAUDE_FINDING),
5716 "a review that names a bug is input, not noise"
5717 );
5718
5719 let mut clean = pr(Checks::Green, &[], 0);
5720 clean.review_comments.push(ReviewComment {
5721 author: "coderabbitai".to_owned(),
5722 path: None,
5723 line: None,
5724 body: CODERABBIT_TRIGGER.to_owned(),
5725 });
5726 clean.review_comments.retain(|c| !is_noise(&c.body));
5727 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5728
5729 let mut found = pr(Checks::Green, &[], 0);
5730 found.review_comments.push(ReviewComment {
5731 author: "claude".to_owned(),
5732 path: None,
5733 line: None,
5734 body: CLAUDE_FINDING.to_owned(),
5735 });
5736 found.review_comments.retain(|c| !is_noise(&c.body));
5737 assert!(matches!(
5738 decide(&found, 0, 4, Duration::ZERO),
5739 Step::Fix { .. }
5740 ));
5741 }
5742
5743 #[test]
5744 fn the_policy_table_holds_for_every_combination_that_matters() {
5745 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5746 (
5747 "pending checks are waited for, even on the last round",
5748 pr(Checks::Pending, &[], 0),
5749 4,
5750 4,
5751 Duration::ZERO,
5752 Step::Wait,
5753 ),
5754 (
5755 "red checks are fixed",
5756 pr(Checks::Red, &["editorconfig"], 0),
5757 0,
5758 4,
5759 Duration::ZERO,
5760 Step::Fix {
5761 reason: "1 check(s) failing: editorconfig".to_owned(),
5762 },
5763 ),
5764 (
5765 "green with comments is fixed, not merged",
5766 pr(Checks::Green, &[], 2),
5767 1,
5768 4,
5769 Duration::ZERO,
5770 Step::Fix {
5771 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5772 .to_owned(),
5773 },
5774 ),
5775 (
5776 "green and clean merges",
5777 pr(Checks::Green, &[], 0),
5778 3,
5779 4,
5780 Duration::ZERO,
5781 Step::Merge,
5782 ),
5783 (
5784 "an unreadable rollup is waited on while the grace lasts",
5785 pr(Checks::Unknown, &[], 0),
5786 0,
5787 4,
5788 Duration::ZERO,
5789 Step::Wait,
5790 ),
5791 (
5792 "an unreadable rollup is never merged once the grace is spent",
5793 pr(Checks::Unknown, &[], 0),
5794 0,
5795 4,
5796 CHECKS_GRACE,
5797 Step::GiveUp {
5798 reason: "no check status is readable on the pull request after 3 minute(s); \
5799 refusing to merge on a guess"
5800 .to_owned(),
5801 },
5802 ),
5803 ];
5804 for (what, state, round, budget, waited, want) in cases {
5805 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5806 }
5807 }
5808
5809 #[test]
5810 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5811 let green = parse_pr(GREEN_OPEN).expect("parse");
5815 assert_eq!(green.blocking, Blocking::No);
5816 let red = parse_pr(RED_OPEN).expect("parse");
5817 assert_eq!(
5818 red.blocking,
5819 Blocking::No,
5820 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5821 );
5822 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5823 let quiet =
5825 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5826 assert_eq!(quiet.blocking, Blocking::Unsaid);
5827 }
5828
5829 #[test]
5830 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5831 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5837 nonblocking.blocking = Blocking::No;
5838 assert_eq!(
5839 decide(&nonblocking, 0, 4, Duration::ZERO),
5840 Step::Merge,
5841 "the forge says nothing is in the way, so nothing is"
5842 );
5843
5844 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5846 blocking.blocking = Blocking::Yes;
5847 assert!(matches!(
5848 decide(&blocking, 0, 4, Duration::ZERO),
5849 Step::Fix { .. }
5850 ));
5851
5852 let mut commented = pr(Checks::Red, &["coverage"], 1);
5855 commented.blocking = Blocking::No;
5856 assert!(matches!(
5857 decide(&commented, 0, 4, Duration::ZERO),
5858 Step::Fix { .. }
5859 ));
5860
5861 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5863 unsaid.blocking = Blocking::Unsaid;
5864 assert!(matches!(
5865 decide(&unsaid, 0, 4, Duration::ZERO),
5866 Step::Fix { .. }
5867 ));
5868 }
5869
5870 #[test]
5871 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5872 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5873 red.blocking = Blocking::No;
5874 assert_eq!(
5875 decide(&red, 0, 4, Duration::ZERO),
5876 Step::Merge,
5877 "announcing must not change the decision"
5878 );
5879 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5880 assert!(said.contains("yukimemi/magi"), "{said}");
5881 assert!(said.contains("#16"), "{said}");
5882 assert!(
5883 said.contains("https://github.com/yukimemi/magi/pull/16"),
5884 "{said}"
5885 );
5886 assert!(
5887 said.contains("test (windows-latest)") && said.contains("coverage"),
5888 "{said}"
5889 );
5890
5891 let green = pr(Checks::Green, &["stale"], 0);
5893 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5894 }
5895
5896 #[test]
5897 fn the_repo_label_comes_from_the_pull_request_url() {
5898 let p = Path::new("/tmp/checkout");
5899 assert_eq!(
5900 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5901 "yukimemi/magi"
5902 );
5903 assert_eq!(repo_label(p, "not a url"), "checkout");
5904 }
5905
5906 #[test]
5907 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5908 let mut conflicted = pr(Checks::Green, &[], 0);
5913 conflicted.blocking = Blocking::Conflict;
5914 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5915
5916 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5920 red.blocking = Blocking::Conflict;
5921 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5922
5923 let mut merged = pr(Checks::Red, &[], 0);
5925 merged.blocking = Blocking::Conflict;
5926 merged.state = PrLifecycle::Merged;
5927 assert_eq!(
5928 decide(&merged, 0, 4, Duration::ZERO),
5929 Step::Done { merged: true }
5930 );
5931 }
5932
5933 #[test]
5934 fn the_forge_verdict_is_read_off_merge_state_status() {
5935 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5938 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5939 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5940 }
5941 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5942 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5943 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5944 for quiet in ["", "UNKNOWN"] {
5947 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5948 assert!(Blocking::of(quiet).stops_a_merge());
5949 }
5950 }
5951
5952 #[test]
5953 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5954 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5955 let jj = "could not determine current branch: failed to run git: not on any branch";
5957
5958 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5959 .expect("the forge says merged, so it merged");
5960 assert!(landed.ok);
5961 assert!(
5962 landed.detail.contains("but the pull request is merged"),
5963 "the record must not read as a clean success: {}",
5964 landed.detail
5965 );
5966 assert!(
5967 landed.detail.contains("not on any branch"),
5968 "and it must keep what the command actually said: {}",
5969 landed.detail
5970 );
5971
5972 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5974 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5975 assert!(merged_after_all(&argv, jj, None).is_none());
5977 }
5978
5979 #[test]
5980 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5981 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5982 state.state = PrLifecycle::Closed;
5983 assert_eq!(
5984 decide(&state, 0, 4, Duration::ZERO),
5985 Step::Done { merged: false },
5986 "a human closing the pull request ends the loop, whatever CI says"
5987 );
5988 }
5989
5990 #[test]
5991 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5992 let red = decide(
5993 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5994 4,
5995 4,
5996 Duration::ZERO,
5997 );
5998 match red {
5999 Step::GiveUp { reason } => {
6000 assert!(reason.contains("editorconfig"), "reason: {reason}");
6001 assert!(reason.contains("test (macos)"), "reason: {reason}");
6002 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6003 }
6004 other => panic!("expected a give-up, got {other:?}"),
6005 }
6006
6007 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6008 match commented {
6009 Step::GiveUp { reason } => {
6010 assert!(reason.contains("unresolved"), "reason: {reason}");
6011 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6012 }
6013 other => panic!("expected a give-up, got {other:?}"),
6014 }
6015 }
6016
6017 #[test]
6018 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6019 let candidate_commit = "magi: candidate A (uncommitted work)";
6020 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6021 let argv = merge_argv(16, &subject);
6022
6023 assert!(argv.contains(&"--squash".to_owned()));
6024 assert!(argv.contains(&"--delete-branch".to_owned()));
6025 assert!(argv.contains(&"--subject".to_owned()));
6026 assert_eq!(
6027 argv.last().map(String::as_str),
6028 Some("add retries to the uploader"),
6029 "the subject must not be the candidate commit message"
6030 );
6031 assert_ne!(subject, candidate_commit);
6032 }
6033
6034 #[test]
6035 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6036 assert_eq!(
6037 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6038 "feat: a queue, an unattended loop, and a phone UI"
6039 );
6040 assert_eq!(
6041 merge_subject("", "# port the retry logic\n\ndetails"),
6042 "port the retry logic",
6043 "an empty title falls back to the task's first line, heading marks stripped"
6044 );
6045 }
6046
6047 #[test]
6048 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6049 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6050 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6051 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6052 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6053 assert_eq!(run_of(""), None);
6054 }
6055
6056 #[test]
6057 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6058 let mut out = Vec::new();
6059 push_if_outstanding(
6060 &mut out,
6061 ReviewComment {
6062 author: "yukimemi".to_owned(),
6063 path: None,
6064 line: None,
6065 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6066 },
6067 );
6068 assert!(out.is_empty());
6069 }
6070
6071 fn run_state() -> RunState {
6075 let mut state = RunState::new(
6076 std::path::PathBuf::from("/repo/magi"),
6077 "main".to_owned(),
6078 "abcdef1234".to_owned(),
6079 "add retries to the uploader".to_owned(),
6080 crate::config::Config::default(),
6081 );
6082 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6087 let nanos = std::time::SystemTime::now()
6088 .duration_since(std::time::UNIX_EPOCH)
6089 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6090 state.id = format!(
6091 "20261004-{nanos:06}-{:04x}",
6092 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6093 );
6094 state
6095 }
6096
6097 fn green_pr() -> PrState {
6098 PrState {
6099 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6100 number: 42,
6101 state: PrLifecycle::Open,
6102 checks: Checks::Green,
6103 blocking: Blocking::No,
6105 failing: Vec::new(),
6106 review_comments: vec![ReviewComment {
6107 author: "coderabbitai".to_owned(),
6108 path: Some("src/land.rs".to_owned()),
6109 line: Some(212),
6110 body: "this branch never checks the exit code".to_owned(),
6111 }],
6112 }
6113 }
6114
6115 #[test]
6116 fn github_facing_land_text_is_english_whatever_the_language() {
6117 let mut state = run_state();
6118 state.config.graph.language = "ja".to_owned();
6119 let comment = stop_comment(&state.id, "checks are still red");
6120 assert!(crate::github_text::check("", &comment).is_empty());
6121 assert!(comment.is_ascii(), "{comment}");
6122 assert!(comment.starts_with(MARKER));
6123
6124 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6125 let ja_at = p.find("Write all prose in ja").unwrap();
6126 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6127 assert!(ja_at < rule_at, "{p}");
6128 assert!(p.contains("stays in Japanese"), "{p}");
6129
6130 state.config.graph.language = "en".to_owned();
6131 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6132 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6133 assert!(!p.contains("does not apply"), "{p}");
6134 }
6135
6136 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6137
6138 fn panel() -> String {
6139 approval_panel(
6140 &run_state(),
6141 &green_pr(),
6142 NUMSTAT,
6143 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6144 &[
6145 "land: ask before merging".to_owned(),
6146 "land: colour the diff".to_owned(),
6147 ],
6148 "feat: merge approval from the phone",
6149 )
6150 }
6151
6152 #[test]
6153 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6154 let html = panel();
6155 for needle in [
6156 "42",
6157 "main",
6158 "src/land.rs",
6159 "src/web.rs",
6160 "assets/logo.png",
6161 "feat: merge approval from the phone",
6162 "land: ask before merging",
6163 "land: colour the diff",
6164 "coderabbitai",
6165 "this branch never checks the exit code",
6166 "green",
6167 ] {
6168 assert!(html.contains(needle), "the panel must state `{needle}`");
6169 }
6170 }
6171
6172 fn winning_candidate(summary: &str) -> Candidate {
6175 Candidate {
6176 index: 0,
6177 label: 'A',
6178 agent: "opus".to_owned(),
6179 branch: "magi/x/A".to_owned(),
6180 worktree: PathBuf::from("/wt/A"),
6181 summary: summary.to_owned(),
6182 stat: String::new(),
6183 files: 1,
6184 commits: 1,
6185 empty: false,
6186 failed: None,
6187 verified_noop: None,
6188 duration_ms: 0,
6189 folded: false,
6190 }
6191 }
6192
6193 fn uncontested_tally() -> Tally {
6194 Tally {
6195 first_choice: BTreeMap::from([('A', 1)]),
6196 borda: BTreeMap::new(),
6197 winner: 'A',
6198 rankings: 1,
6199 unanimous_initial: true,
6200 deliberated: false,
6201 changed_votes: 0,
6202 unanimous_final: true,
6203 tie_break: None,
6204 judges: 1,
6205 present: 1,
6206 quorum: 1,
6207 met_quorum: true,
6208 uncontested: None,
6209 }
6210 }
6211
6212 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6213 ReviewRecord {
6214 attempts: 0,
6215 reviewer,
6216 agent: agent.to_owned(),
6217 summary: summary.to_owned(),
6218 findings: Vec::new(),
6219 vote: None,
6220 failed: None,
6221 duration_ms: 0,
6222 }
6223 }
6224
6225 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6226 let answered = reviews.len();
6227 ReviewRound {
6228 round,
6229 head: "abc1234".to_owned(),
6230 verified_head: None,
6231 verified_at: None,
6232 reviews,
6233 e2e: Vec::new(),
6234 verify_retried: false,
6235 e2e_deferred: false,
6236 e2e_defer_reason: None,
6237 fix: None,
6238 blocking: 0,
6239 answered,
6240 expected: answered,
6241 clean: true,
6242 progressed: false,
6243 vote_split: false,
6244 reconsideration: Vec::new(),
6245 verdict: None,
6246 }
6247 }
6248
6249 #[test]
6250 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6251 let en = panel();
6252 assert!(en.contains("Task"), "{en}");
6253 assert!(en.contains("add retries to the uploader"), "{en}");
6254
6255 let mut state = run_state();
6256 state.config.graph.language = "ja".to_owned();
6257 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6258 assert!(ja.contains("タスク"), "{ja}");
6259 assert!(
6260 ja.contains("add retries to the uploader"),
6261 "the task itself is not translated: {ja}"
6262 );
6263 }
6264
6265 #[test]
6266 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6267 let html = panel();
6271 assert!(!html.contains("What changed"), "{html}");
6272 assert!(!html.contains("Review verdict"), "{html}");
6273 }
6274
6275 #[test]
6276 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6277 let mut state = run_state();
6278 state.candidates = vec![winning_candidate("")];
6279 state.tally = Some(uncontested_tally());
6280 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6281 assert!(
6282 !html.contains("What changed"),
6283 "an empty summary must not render an empty box: {html}"
6284 );
6285 }
6286
6287 #[test]
6288 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6289 let mut state = run_state();
6290 state.candidates = vec![winning_candidate(
6291 "Added a retry loop around the uploader PUT call.",
6292 )];
6293 state.tally = Some(uncontested_tally());
6294 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6295 assert!(en.contains("What changed"), "{en}");
6296 assert!(
6297 en.contains("Added a retry loop around the uploader PUT call."),
6298 "{en}"
6299 );
6300
6301 state.config.graph.language = "ja".to_owned();
6302 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6303 assert!(ja.contains("変更内容"), "{ja}");
6304 assert!(
6305 ja.contains("Added a retry loop around the uploader PUT call."),
6306 "{ja}"
6307 );
6308 }
6309
6310 #[test]
6311 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6312 let mut state = run_state();
6313 state.reviews = vec![
6314 review_round(
6315 1,
6316 vec![review_record(1, "alpha", "found a race, sent back")],
6317 ),
6318 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6319 ];
6320 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6321 assert!(en.contains("Review verdict"), "{en}");
6322 assert!(en.contains("race is fixed, clean"), "{en}");
6323 assert!(
6324 !en.contains("found a race, sent back"),
6325 "only the round that actually cleared the merge should show: {en}"
6326 );
6327
6328 state.config.graph.language = "ja".to_owned();
6329 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6330 assert!(ja.contains("レビューの結論"), "{ja}");
6331 assert!(ja.contains("レビュアー"), "{ja}");
6332 assert!(ja.contains("race is fixed, clean"), "{ja}");
6333 }
6334
6335 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6341 ReviewRecord {
6342 attempts: 0,
6343 reviewer,
6344 agent: agent.to_owned(),
6345 summary: String::new(),
6346 findings: Vec::new(),
6347 vote: None,
6348 failed: Some(reason.to_owned()),
6349 duration_ms: 0,
6350 }
6351 }
6352
6353 #[test]
6354 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6355 let mut state = run_state();
6356 state.reviews = vec![review_round(
6357 1,
6358 vec![
6359 review_record(1, "alpha", "clean, nothing to add"),
6360 unanswered_review_record(2, "beta", "timed out"),
6361 ],
6362 )];
6363 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6364 assert!(en.contains("clean, nothing to add"), "{en}");
6365 assert!(
6366 en.contains("produced no answer: timed out"),
6367 "a seat that never answered must say so, not render a blank box: {en}"
6368 );
6369 assert!(
6370 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6371 "no reviewer box may be left empty: {en}"
6372 );
6373
6374 state.config.graph.language = "ja".to_owned();
6375 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6376 assert!(ja.contains("回答なし: timed out"), "{ja}");
6377 }
6378
6379 #[test]
6380 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6381 let html = panel();
6382 assert!(!html.contains("<script"), "no script survives the csp");
6383 assert!(!html.contains("<form"), "form-action is 'none'");
6384 let pr = green_pr();
6385 assert_eq!(
6386 html.matches("http").count(),
6387 html.matches(pr.url.as_str()).count(),
6388 "the only http url in the panel is the pull request's own link"
6389 );
6390 assert!(
6391 html.contains("target=\"_blank\""),
6392 "the PR link must open in a new tab so it escapes the iframe"
6393 );
6394 assert!(
6395 html.contains("rel=\"noopener noreferrer\""),
6396 "external links opening in new tabs must carry noopener noreferrer"
6397 );
6398 }
6399
6400 #[test]
6401 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6402 let html = panel();
6403 assert!(
6404 html.contains(">+</span>"),
6405 "an added line carries a `+` in the gutter, not only a background"
6406 );
6407 assert!(
6408 html.contains(">-</span>"),
6409 "a removed line carries a `-` in the gutter, not only a background"
6410 );
6411 assert!(
6412 html.contains(">new line</span>"),
6413 "the marker is moved to the gutter, so the body is printed once without it"
6414 );
6415 }
6416
6417 #[test]
6418 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6419 let total = DIFF_MAX_LINES + 100;
6420 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6421 let html = approval_panel(
6422 &run_state(),
6423 &green_pr(),
6424 NUMSTAT,
6425 &diff,
6426 &[],
6427 "feat: something long",
6428 );
6429 assert!(
6430 html.contains(&format!("100 of {total} diff lines omitted")),
6431 "the note must say exactly how much was cut"
6432 );
6433 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6434 assert!(
6435 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6436 "nothing past the threshold is rendered"
6437 );
6438 assert!(
6439 html.contains("/repo/magi"),
6440 "the note says where the rest is"
6441 );
6442 }
6443
6444 #[test]
6445 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6446 let html = approval_panel(
6447 &run_state(),
6448 &green_pr(),
6449 "1\t2\tsrc/<b>&\"x\"'.rs",
6450 "",
6451 &[],
6452 "subject",
6453 );
6454 assert!(html.contains("src/<b>&"x"'.rs"));
6455 assert!(
6456 !html.contains("<b>"),
6457 "an agent-influenced path must never become markup"
6458 );
6459 }
6460
6461 #[tokio::test]
6462 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6463 let a = std::path::PathBuf::from("/repo/a");
6464 let b = std::path::PathBuf::from("/repo/b");
6465
6466 let held = repo_merge_lock(&a).lock_owned().await;
6467
6468 assert!(
6471 repo_merge_lock(&a).try_lock().is_err(),
6472 "a second merge into the same repository must not proceed concurrently"
6473 );
6474
6475 assert!(
6479 repo_merge_lock(&b).try_lock().is_ok(),
6480 "a different repository's merge lock must be independent"
6481 );
6482
6483 drop(held);
6484 assert!(
6485 repo_merge_lock(&a).try_lock().is_ok(),
6486 "the lock is released once the holder is done"
6487 );
6488 }
6489
6490 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6491 let mut state = landing_state();
6492 let mut rec = review_record(1, "alpha", "precise rejection reason");
6493 rec.vote = Some(if reject {
6494 crate::verdict::ReviewVote::Reject
6495 } else {
6496 crate::verdict::ReviewVote::Approve
6497 });
6498 if major {
6499 rec.findings = contested().findings;
6500 }
6501 let mut round = review_round(1, vec![rec]);
6502 round.clean = false;
6503 round.blocking = usize::from(major);
6504 state.reviews.push(round);
6505 state.config.graph.review_rounds = 1;
6506 state
6507 }
6508
6509 #[test]
6510 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6511 for (major, reject) in [(true, false), (false, true), (true, true)] {
6512 let state = repair_state(major, reject);
6513 assert!(repair_available(&state));
6514 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
6515 }
6516 assert!(!repair_available(&repair_state(false, false)));
6517 let mut clean = repair_state(true, false);
6518 clean.reviews[0].clean = true;
6519 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
6520 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6521 }
6522
6523 #[tokio::test]
6524 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6525 let mut state = repair_state(true, false);
6526 let pr = green_pr();
6527 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6528 .await
6529 .unwrap();
6530 let store = ask::Questions::open();
6531 let id = state.land_approval.as_ref().unwrap().question.clone();
6532 store
6534 .update(&id, |q| {
6535 q.choices = vec![APPROVE.into(), HOLD.into()];
6536 Ok(())
6537 })
6538 .unwrap();
6539 let upgraded = store.get(&id).unwrap();
6540 assert_eq!(upgraded.choices, approval_choices(&state));
6541 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6542 assert!(
6543 store
6544 .list()
6545 .iter()
6546 .any(|q| q.id == id && q.choices == upgraded.choices)
6547 );
6548 assert!(
6549 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6550 );
6551 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6552 store
6553 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6554 .unwrap();
6555 store
6558 .update(&id, |q| {
6559 q.actions.clear();
6560 Ok(())
6561 })
6562 .unwrap();
6563 assert_eq!(
6564 store.get(&id).unwrap().chosen_action(),
6565 Some(&ask::ChoiceAction::Resume {
6566 run: state.id.clone()
6567 })
6568 );
6569 let binding = state.land_approval.clone();
6570 assert_eq!(
6571 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6572 .await
6573 .unwrap(),
6574 ApprovalGate::Repair
6575 );
6576 assert!(state.land_approval.is_none());
6577 assert_eq!(state.merge_repairs.len(), 1);
6578 let request = &state.merge_repairs[0];
6579 assert_eq!(request.question, id);
6580 assert_eq!(request.head, "abc");
6581 assert_eq!(request.pr, pr.url);
6582 assert_eq!(
6583 request.reviews[0].reviews[0].summary,
6584 "precise rejection reason"
6585 );
6586 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6587 assert_eq!(
6588 request.reviews[0].reviews[0].findings[0].detail,
6589 state.reviews[0].reviews[0].findings[0].detail
6590 );
6591 state.land_approval = binding;
6593 assert_eq!(
6594 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6595 .await
6596 .unwrap(),
6597 ApprovalGate::Repair
6598 );
6599 assert_eq!(state.merge_repairs.len(), 1);
6600 }
6601
6602 #[tokio::test]
6603 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6604 for choice in [REPAIR, HOLD] {
6605 let mut state = repair_state(false, true);
6606 let observed = seen("abc", Checks::Green, "CLEAN", false);
6607 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6608 .await
6609 .unwrap();
6610 let id = state.land_approval.as_ref().unwrap().question.clone();
6611 ask::Questions::open()
6612 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6613 .unwrap();
6614 let forge = Scripted::new(vec![observed], vec![(true, "")]);
6615 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6616 .await
6617 .unwrap();
6618 assert!(
6619 forge
6620 .argvs()
6621 .iter()
6622 .all(|a| a.iter().any(|s| s == "--disable-auto"))
6623 );
6624 assert!(!forge.calls().contains(&"fix"));
6625 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6626 assert_ne!(state.status, RunStatus::Merged);
6627 }
6628 }
6629
6630 #[test]
6631 fn only_the_merge_choice_merges_and_silence_holds() {
6632 let table = [
6633 (None, Approval::Hold),
6634 (Some("merge"), Approval::Merge),
6635 (Some(" merge\n"), Approval::Merge),
6636 (Some("hold"), Approval::Hold),
6637 (Some(""), Approval::Hold),
6638 (Some("yes"), Approval::Hold),
6639 ];
6640 for (answer, want) in table {
6641 assert_eq!(
6642 approval(answer),
6643 want,
6644 "answer {answer:?} must resolve to {want:?}"
6645 );
6646 }
6647 }
6648
6649 #[tokio::test]
6650 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6651 let mut state = landing_state();
6652 state.config.graph.land_approval = true;
6653 let pr = green_pr();
6654
6655 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6656 .await
6657 .unwrap();
6658 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6659 assert!(
6660 !state.parked,
6661 "approval_gate itself never sets `parked`; only its caller does"
6662 );
6663
6664 let store = ask::Questions::open();
6665 let filed: Vec<_> = store
6666 .list()
6667 .into_iter()
6668 .filter(|q| q.run == state.id)
6669 .collect();
6670 assert_eq!(filed.len(), 1, "exactly one question is filed");
6671 assert_eq!(filed[0].node, APPROVAL_NODE);
6672 assert_eq!(filed[0].choices, approval_choices(&state));
6673 assert!(filed[0].status.open());
6674
6675 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6679 .await
6680 .unwrap();
6681 assert_eq!(again, ApprovalGate::Pending);
6682 let still_one = store
6683 .list()
6684 .into_iter()
6685 .filter(|q| q.run == state.id)
6686 .count();
6687 assert_eq!(
6688 still_one, 1,
6689 "asking twice must not double-file the question"
6690 );
6691 }
6692
6693 #[tokio::test]
6694 async fn approving_the_existing_question_is_read_back_as_approved() {
6695 crate::run::pin_test_home();
6696 let mut state = run_state();
6697 state.config.graph.land_approval = true;
6698 let pr = green_pr();
6699 assert_eq!(
6700 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6701 .await
6702 .unwrap(),
6703 ApprovalGate::Pending
6704 );
6705
6706 let store = ask::Questions::open();
6707 let mut q = store
6708 .list()
6709 .into_iter()
6710 .find(|q| q.run == state.id)
6711 .expect("filed above");
6712 let talks = crate::talk::Talks::open();
6713 let cfg = crate::config::Config {
6714 agents: vec![crate::config::AgentSpec {
6715 id: "mock".into(),
6716 kind: crate::config::AgentKind::Command,
6717 model: None,
6718 command: vec!["true".into()],
6719 extra_args: Vec::new(),
6720 env: Default::default(),
6721 prompt_delivery: None,
6722 }],
6723 ..crate::config::Config::default()
6724 };
6725 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6726 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6727 crate::talk::drain(&mut talk, &talks).unwrap();
6728 assert_eq!(
6729 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6730 .await
6731 .unwrap(),
6732 ApprovalGate::Pending,
6733 "consultation cannot approve or hold the merge"
6734 );
6735 q = store.get(&q.id).unwrap();
6736 assert!(q.answer.is_none());
6737 talk.turns.push(crate::talk::Turn {
6738 sender: None,
6739 breaks: Some(Vec::new()),
6740 messages: Vec::new(),
6741 posted_by: None,
6742 who: crate::talk::Who::Operator,
6743 body: "merge abc".into(),
6744 at: Timestamp::now(),
6745 attachments: Vec::new(),
6746 usage: None,
6747 });
6748 talks.put(&mut talk).unwrap();
6749 crate::consult::validate_answer(
6750 &q,
6751 &talks,
6752 &talk.id,
6753 crate::queue::CHAT_NODE,
6754 APPROVE,
6755 Some("merge abc"),
6756 )
6757 .unwrap();
6758 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6759 store.put(&mut q).unwrap();
6760
6761 assert_eq!(
6762 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6763 .await
6764 .unwrap(),
6765 ApprovalGate::Approved
6766 );
6767 }
6768
6769 #[tokio::test]
6770 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6771 crate::run::pin_test_home();
6772 let store = ask::Questions::open();
6773
6774 let mut held_state = run_state();
6775 held_state.config.graph.land_approval = true;
6776 let pr = green_pr();
6777 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6778 .await
6779 .unwrap();
6780 let mut q = store
6781 .list()
6782 .into_iter()
6783 .find(|q| q.run == held_state.id)
6784 .expect("filed above");
6785 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6786 store.put(&mut q).unwrap();
6787 assert_eq!(
6788 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6789 .await
6790 .unwrap(),
6791 ApprovalGate::Held
6792 );
6793
6794 let mut abandoned_state = run_state();
6795 abandoned_state.config.graph.land_approval = true;
6796 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6797 .await
6798 .unwrap();
6799 let mut q = store
6800 .list()
6801 .into_iter()
6802 .find(|q| q.run == abandoned_state.id)
6803 .expect("filed above");
6804 q.abandon("no answer within the timeout");
6805 store.put(&mut q).unwrap();
6806 assert_eq!(
6807 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6808 .await
6809 .unwrap(),
6810 ApprovalGate::Held,
6811 "silence must never merge"
6812 );
6813 }
6814
6815 fn contested() -> ContestedHandoff {
6816 let finding = |id: &str, n: u32| crate::verdict::Finding {
6817 id: id.to_owned(),
6818 severity: crate::verdict::Severity::Major,
6819 file: Some("src/a.rs".to_owned()),
6820 line: Some(n),
6821 title: format!("problem {id}"),
6822 detail: String::new(),
6823 };
6824 ContestedHandoff {
6825 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6826 rejecters: vec![(1, "alpha".to_owned())],
6827 }
6828 }
6829
6830 #[test]
6831 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6832 let mut state = run_state();
6833 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6834 state.contested_handoff = Some(contested());
6835 assert!(contested_to_ask(&state).is_some());
6836 state.config.graph.hold_contested_merge = false;
6837 assert!(
6838 contested_to_ask(&state).is_none(),
6839 "the switch restores today"
6840 );
6841 }
6842
6843 #[test]
6844 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6845 let q = ask::Question::new(
6846 "run-1".to_owned(),
6847 APPROVAL_NODE.to_owned(),
6848 "land".to_owned(),
6849 "Merge?".to_owned(),
6850 String::new(),
6851 vec![APPROVE.to_owned(), HOLD.to_owned()],
6852 );
6853 let none = deputy_brief(&q, None);
6854 assert!(none.contains("could not be read"), "{none}");
6855 assert!(none.contains("Silence is a hold"), "{none}");
6856
6857 let mut state = run_state();
6858 state.pr = Some(crate::run::PrRecord {
6859 url: "https://example.test/pull/7".to_owned(),
6860 number: 7,
6861 state: "open".to_owned(),
6862 checks: "green".to_owned(),
6863 round: 0,
6864 rounds: 3,
6865 red_at_merge: Vec::new(),
6866 });
6867 state.contested_handoff = Some(contested());
6868 let b = deputy_brief(&q, Some(&state));
6869 assert!(b.contains("https://example.test/pull/7"), "{b}");
6870 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6871 assert!(b.contains("#1"), "the rejecting seat: {b}");
6872 state.contested_handoff = None;
6873 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6874 }
6875
6876 #[test]
6877 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6878 for lang in ["en", "ja"] {
6879 let mut cfg = crate::config::Config::default();
6880 cfg.graph.language = lang.to_owned();
6881 let w = words(&cfg.graph.language);
6882 let text = w.approval_detail(
6883 "https://github.com/yukimemi/magi/pull/42",
6884 "main",
6885 "feat: x",
6886 Some(&contested()),
6887 );
6888 assert!(text.contains("pull/42"), "{text}");
6889 assert!(
6890 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6891 "{text}"
6892 );
6893 assert!(text.contains("R3-1-5"), "{text}");
6894 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6895 assert!(text.contains("2"), "the rest are counted: {text}");
6896 assert!(text.contains("#1 (alpha)"), "{text}");
6897 }
6898 let plain = words("en").approval_detail("u", "main", "s", None);
6899 assert!(!plain.contains("reject"), "{plain}");
6900 }
6901
6902 #[tokio::test]
6903 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6904 crate::run::pin_test_home();
6905 let mut state = run_state();
6906 state.config.graph.land_approval = false;
6907 state.contested_handoff = Some(contested());
6908 let pr = green_pr();
6909 let c = contested_to_ask(&state);
6910 assert_eq!(
6911 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6912 .await
6913 .unwrap(),
6914 ApprovalGate::Pending,
6915 "silence is a hold"
6916 );
6917 let store = ask::Questions::open();
6918 let filed: Vec<_> = store
6919 .list()
6920 .into_iter()
6921 .filter(|q| q.run == state.id)
6922 .collect();
6923 assert_eq!(filed.len(), 1);
6924 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6925
6926 assert_eq!(
6927 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6928 .await
6929 .unwrap(),
6930 ApprovalGate::Pending
6931 );
6932 let mut q = store
6933 .list()
6934 .into_iter()
6935 .find(|q| q.run == state.id)
6936 .unwrap();
6937 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6938 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6939 store.put(&mut q).unwrap();
6940 assert_eq!(
6941 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6942 .await
6943 .unwrap(),
6944 ApprovalGate::Approved
6945 );
6946 }
6947
6948 #[test]
6949 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6950 let rows = parse_numstat(NUMSTAT);
6951 assert_eq!(
6952 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6953 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6954 );
6955 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6956 }
6957 #[test]
6958 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6959 let mut state = run_state();
6963 state.config.graph.language = "ja".to_owned();
6964 let pr = green_pr();
6965 let commits = ["c1".to_owned()];
6966
6967 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6968 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6969 assert!(ja.contains("squash されるコミット"), "{ja}");
6970 assert!(ja.contains("レビューコメント"), "{ja}");
6971 assert!(ja.contains("差分"), "{ja}");
6972 assert!(
6973 !ja.contains("Commits being squashed"),
6974 "no English left over"
6975 );
6976
6977 let w = words("ja");
6978 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6979 assert!(
6980 w.approval_detail("http://x/1", "main", "feat: x", None)
6981 .contains("パネル")
6982 );
6983
6984 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6986 assert!(ja.contains("feat: x"), "nor is the merge subject");
6987
6988 state.config.graph.language = "en".to_owned();
6991 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6992 assert!(en.contains("Commits being squashed"), "{en}");
6993 assert_eq!(words("Klingon").html_lang, "en");
6994 }
6995
6996 #[test]
7000 fn pick_open_pr_classifies_by_count_and_base() {
7001 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7002 assert_eq!(
7003 pick_open_pr(one, "main").unwrap(),
7004 OpenPr::One {
7005 url: "https://x/pull/58".into(),
7006 title: "t".into()
7007 }
7008 );
7009 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7010 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7011 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7012 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7013 assert_eq!(
7014 pick_open_pr(two, "main").unwrap(),
7015 OpenPr::Many(vec!["u1".into(), "u2".into()])
7016 );
7017 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7019 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7020 assert!(pick_open_pr("not json", "main").is_err());
7021 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7023 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7024 }
7025
7026 #[test]
7027 fn pick_merged_pr_picks_the_unique_match() {
7028 let json = r#"[
7029 {"url": "https://github.com/o/r/pull/42", "number": 42,
7030 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7031 ]"#;
7032 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7033 let found = pick_merged_pr(json, "main", created_at)
7034 .expect("valid json")
7035 .expect("one unambiguous match");
7036 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7037 assert_eq!(found.number, 42);
7038 }
7039
7040 #[test]
7044 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7045 let json = r#"[
7046 {"url": "https://github.com/o/r/pull/42", "number": 42,
7047 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7048 {"url": "https://github.com/o/r/pull/43", "number": 43,
7049 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
7050 ]"#;
7051 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7052 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7053 }
7054
7055 #[test]
7059 fn pick_merged_pr_ignores_a_different_base_branch() {
7060 let json = r#"[
7061 {"url": "https://github.com/o/r/pull/42", "number": 42,
7062 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7063 ]"#;
7064 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7065 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7066 }
7067
7068 #[test]
7071 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7072 let json = r#"[
7073 {"url": "https://github.com/o/r/pull/42", "number": 42,
7074 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7075 ]"#;
7076 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7077 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7078 }
7079
7080 #[test]
7081 fn slug_of_pr_url_reads_host_owner_and_repo() {
7082 assert_eq!(
7083 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7084 Some("github.com/yukimemi/shun")
7085 );
7086 }
7087
7088 #[test]
7089 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7090 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7091 assert_eq!(slug_of_pr_url("not a url at all"), None);
7092 assert_eq!(slug_of_pr_url("https://github.com"), None);
7093 }
7094
7095 #[test]
7096 fn slug_of_repo_url_reads_host_owner_and_repo() {
7097 assert_eq!(
7098 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7099 Some("github.com/yukimemi/magi")
7100 );
7101 assert_eq!(slug_of_repo_url("https://github.com"), None);
7102 }
7103
7104 #[test]
7105 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7106 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7107 .expect("same repo, different case");
7108 }
7109
7110 #[test]
7115 fn ensure_same_repo_refuses_a_different_repo() {
7116 let err =
7117 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7118 let msg = format!("{err:#}");
7119 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7120 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7121 }
7122
7123 #[test]
7128 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7129 let err = ensure_same_repo(
7130 "github.com/yukimemi/magi",
7131 "github.example.com/yukimemi/magi",
7132 )
7133 .unwrap_err();
7134 let msg = format!("{err:#}");
7135 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7136 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7137 }
7138
7139 #[tokio::test]
7144 async fn find_external_merge_returns_none_without_a_winner() {
7145 let state = RunState::new(
7146 PathBuf::from("/no/such/repo"),
7147 "main".to_owned(),
7148 "0000000000000000000000000000000000000000".to_owned(),
7149 "irrelevant".to_owned(),
7150 crate::config::Config::default(),
7151 );
7152 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7153 }
7154
7155 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7156 let mut run = RunState::new(
7157 PathBuf::from(repo),
7158 "main".to_owned(),
7159 "abcdef1234".to_owned(),
7160 "x".to_owned(),
7161 crate::config::Config::default(),
7162 );
7163 run.id = id.to_owned();
7164 run.status = status;
7165 run.pr = Some(crate::run::PrRecord {
7166 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7167 url: url.to_owned(),
7168 state: state.to_owned(),
7169 checks: "red".to_owned(),
7170 round: 0,
7171 rounds: 2,
7172 red_at_merge: Vec::new(),
7173 });
7174 run.save_under(home).unwrap();
7175 }
7176
7177 fn recorded(home: &Path, id: &str) -> String {
7178 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7179 serde_json::from_str::<RunState>(&body)
7180 .unwrap()
7181 .pr
7182 .unwrap()
7183 .state
7184 }
7185
7186 const PR: &str = "https://github.com/o/r/pull/7";
7187
7188 #[test]
7189 fn write_through_updates_predecessors_and_siblings_only() {
7190 let tmp = tempfile::tempdir().unwrap();
7191 let h = tmp.path();
7192 pr_run(
7193 h,
7194 "20261004-100000-aaaa",
7195 "/repo/r",
7196 RunStatus::Superseded,
7197 PR,
7198 "open",
7199 );
7200 pr_run(
7201 h,
7202 "20261004-100100-bbbb",
7203 "/repo/r",
7204 RunStatus::Blocked,
7205 PR,
7206 "open",
7207 );
7208 pr_run(
7210 h,
7211 "20261004-100200-cccc",
7212 "/repo/r",
7213 RunStatus::Landing,
7214 PR,
7215 "open",
7216 );
7217 pr_run(
7219 h,
7220 "20261004-100300-dddd",
7221 "/repo/other",
7222 RunStatus::Blocked,
7223 "https://github.com/o/other/pull/7",
7224 "open",
7225 );
7226 pr_run(
7228 h,
7229 "20261004-100400-eeee",
7230 "/repo/r",
7231 RunStatus::Blocked,
7232 "https://github.com/o/r/pull/8",
7233 "open",
7234 );
7235 pr_run(
7236 h,
7237 "20261004-100500-ffff",
7238 "/repo/r",
7239 RunStatus::Merged,
7240 PR,
7241 "open",
7242 );
7243 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7244
7245 assert_eq!(
7246 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7247 2
7248 );
7249 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7250 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7251 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7252 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7253 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7254 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7256 assert_eq!(
7258 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7259 0
7260 );
7261 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7262 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7263 }
7264
7265 #[test]
7266 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7267 let tmp = tempfile::tempdir().unwrap();
7268 let h = tmp.path();
7269 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7270 pr_run(
7271 h,
7272 "20261004-100000-aaaa",
7273 "/repo/r",
7274 RunStatus::Superseded,
7275 &url(1),
7276 "open",
7277 );
7278 pr_run(
7279 h,
7280 "20261004-100100-bbbb",
7281 "/repo/r",
7282 RunStatus::Blocked,
7283 &url(2),
7284 "open",
7285 );
7286 pr_run(
7287 h,
7288 "20261004-100200-cccc",
7289 "/repo/r",
7290 RunStatus::Ready,
7291 &url(3),
7292 "open",
7293 );
7294 pr_run(
7295 h,
7296 "20261004-100300-dddd",
7297 "/repo/r",
7298 RunStatus::Ready,
7299 &url(4),
7300 "open",
7301 );
7302 pr_run(
7303 h,
7304 "20261004-100400-eeee",
7305 "/repo/r",
7306 RunStatus::Implementing,
7307 &url(1),
7308 "open",
7309 );
7310 assert_eq!(stale_open_prs(h).len(), 4);
7311
7312 let mut known = BTreeMap::new();
7313 known.insert(url(1), PrLifecycle::Merged);
7314 known.insert(url(2), PrLifecycle::Closed);
7315 known.insert(url(3), PrLifecycle::Open);
7316 assert_eq!(apply_pr_states(h, &known), 2);
7318 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7319 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
7320 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7321 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7322 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7323 assert_eq!(apply_pr_states(h, &known), 0);
7324 }
7325
7326 use std::collections::VecDeque;
7329 use std::sync::Mutex;
7330
7331 struct Scripted {
7334 views: Mutex<VecDeque<Seen>>,
7335 merges: Mutex<VecDeque<(bool, String)>>,
7336 fix: Mutex<Option<Fixed>>,
7337 log: Mutex<Vec<&'static str>>,
7338 argvs: Mutex<Vec<Vec<String>>>,
7339 required: Mutex<Option<BTreeSet<String>>>,
7340 merged: Mutex<bool>,
7343 queued: Mutex<bool>,
7344 unreadable_after_merge: Mutex<bool>,
7346 reruns: Mutex<Vec<String>>,
7348 rerun_fails: Mutex<bool>,
7349 clock: Mutex<i64>,
7351 }
7352
7353 impl Scripted {
7354 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
7355 Self {
7356 views: Mutex::new(views.into()),
7357 merges: Mutex::new(
7358 merges
7359 .into_iter()
7360 .map(|(ok, m)| (ok, m.to_owned()))
7361 .collect(),
7362 ),
7363 fix: Mutex::new(None),
7364 log: Mutex::new(Vec::new()),
7365 argvs: Mutex::new(Vec::new()),
7366 required: Mutex::new(None),
7367 merged: Mutex::new(false),
7368 queued: Mutex::new(false),
7369 unreadable_after_merge: Mutex::new(false),
7370 reruns: Mutex::new(Vec::new()),
7371 rerun_fails: Mutex::new(false),
7372 clock: Mutex::new(1_000),
7373 }
7374 }
7375 fn argvs(&self) -> Vec<Vec<String>> {
7376 self.argvs.lock().unwrap().clone()
7377 }
7378 fn calls(&self) -> Vec<&'static str> {
7379 self.log.lock().unwrap().clone()
7380 }
7381 }
7382
7383 impl Forge for Scripted {
7384 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7385 self.log.lock().unwrap().push("view");
7386 if *self.unreadable_after_merge.lock().unwrap()
7387 && !self.argvs.lock().unwrap().is_empty()
7388 {
7389 anyhow::bail!("forge unreachable");
7390 }
7391 let mut v = self.views.lock().unwrap();
7392 let mut seen = if v.len() > 1 {
7393 v.pop_front().unwrap()
7394 } else {
7395 v[0].clone()
7396 };
7397 if *self.merged.lock().unwrap() {
7398 seen.pr.state = PrLifecycle::Merged;
7399 }
7400 Ok(seen)
7401 }
7402 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7403 self.log.lock().unwrap().push("merge");
7404 self.argvs.lock().unwrap().push(argv.to_vec());
7405 let out = self
7406 .merges
7407 .lock()
7408 .unwrap()
7409 .pop_front()
7410 .expect("unscripted merge");
7411 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7412 {
7413 *self.merged.lock().unwrap() = true;
7414 }
7415 Ok(out)
7416 }
7417 async fn poll(&self) {
7418 self.log.lock().unwrap().push("poll");
7419 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7420 }
7421 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7422 self.log.lock().unwrap().push("rerun");
7423 self.reruns.lock().unwrap().push(run.to_owned());
7424 if *self.rerun_fails.lock().unwrap() {
7425 anyhow::bail!("gh run rerun: no");
7426 }
7427 Ok(())
7428 }
7429 fn now(&self) -> i64 {
7430 *self.clock.lock().unwrap()
7431 }
7432 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7433 self.required.lock().unwrap().clone()
7434 }
7435 async fn fix(
7436 &self,
7437 _state: &mut RunState,
7438 _pr: &PrState,
7439 _round: usize,
7440 _budget: usize,
7441 _reason: &str,
7442 _logs: &str,
7443 ) -> Result<Fixed> {
7444 self.log.lock().unwrap().push("fix");
7445 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7446 }
7447 }
7448
7449 const REFUSED: &str =
7450 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7451
7452 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7453 let mut pr = green_pr();
7454 pr.checks = checks;
7455 pr.blocking = Blocking::of(merge_state);
7456 if !comments {
7457 pr.review_comments.clear();
7458 }
7459 Seen {
7460 pr,
7461 title: "feat: x".to_owned(),
7462 failing_urls: Vec::new(),
7463 head: head.to_owned(),
7464 rollup_head: head.to_owned(),
7465 merge_state: merge_state.to_owned(),
7466 contexts: Vec::new(),
7467 base: "main".to_owned(),
7468 }
7469 }
7470
7471 fn landing_state() -> RunState {
7472 crate::run::pin_test_home();
7473 let mut state = run_state();
7474 state.config.graph.land_approval = false;
7475 state
7476 }
7477
7478 #[test]
7479 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7480 assert!(!awaiting_new_head(None, "aaa"));
7481 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7482 assert!(awaiting_new_head(Some("abc123"), "def456"));
7483 assert!(awaiting_new_head(Some("abc123"), ""));
7484 }
7485
7486 #[test]
7487 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7488 let open = |c, m: &str| seen("a", c, m, false);
7489 let table = [
7490 (None, false, Refused::Pending),
7491 (
7492 Some(open(Checks::Pending, "BLOCKED")),
7493 false,
7494 Refused::Pending,
7495 ),
7496 (
7497 Some(open(Checks::Unknown, "BLOCKED")),
7498 false,
7499 Refused::Pending,
7500 ),
7501 (
7502 Some(open(Checks::Green, "UNKNOWN")),
7503 false,
7504 Refused::Pending,
7505 ),
7506 (Some(open(Checks::Green, "")), false, Refused::Pending),
7507 (
7508 Some(open(Checks::Green, "BLOCKED")),
7509 false,
7510 Refused::Recheck,
7511 ),
7512 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7513 ];
7514 for (after, rechecked, want) in table {
7515 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7516 }
7517 let mut closed = open(Checks::Green, "CLEAN");
7518 closed.pr.state = PrLifecycle::Closed;
7519 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7520 }
7521
7522 #[tokio::test]
7523 async fn a_normal_landing_merges_on_the_first_look() {
7524 let mut state = landing_state();
7525 let forge = Scripted::new(
7526 vec![seen("a", Checks::Green, "CLEAN", false)],
7527 vec![(true, "")],
7528 );
7529 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7530 .await
7531 .unwrap();
7532 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7534 assert_eq!(state.status, RunStatus::Merged);
7535 }
7536
7537 #[tokio::test]
7538 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7539 let mut state = landing_state();
7540 let forge = Scripted::new(
7541 vec![seen("a", Checks::Green, "CLEAN", false)],
7542 std::iter::repeat_n((true, ""), 100).collect(),
7543 );
7544 *forge.queued.lock().unwrap() = true;
7545 let task = async {
7546 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7547 .await
7548 .unwrap();
7549 };
7550 task.await;
7553 assert_ne!(state.status, RunStatus::Merged);
7554 }
7555
7556 #[tokio::test]
7557 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7558 let mut state = landing_state();
7559 let forge = Scripted::new(
7560 vec![seen("a", Checks::Green, "CLEAN", false)],
7561 std::iter::repeat_n((true, ""), 100).collect(),
7562 );
7563 *forge.unreadable_after_merge.lock().unwrap() = true;
7564 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7565 .await
7566 .ok();
7567 assert_ne!(state.status, RunStatus::Merged);
7568 }
7569
7570 #[tokio::test]
7571 async fn a_task_public_head_is_awaited_on_land_entry() {
7572 let mut state = landing_state();
7573 state.pr_head = Some("magi/task-abcd-1234".into());
7574 state.pr_pushed_head = Some("new".into());
7575 let forge = Scripted::new(
7576 vec![
7577 seen("old", Checks::Green, "CLEAN", false),
7578 seen("new", Checks::Green, "CLEAN", false),
7579 ],
7580 vec![(true, "")],
7581 );
7582 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7583 .await
7584 .unwrap();
7585 assert_eq!(
7586 forge.calls(),
7587 ["view", "poll", "view", "view", "merge", "view"]
7588 );
7589 assert_eq!(state.status, RunStatus::Merged);
7590 }
7591
7592 #[tokio::test]
7593 async fn a_superseded_public_head_owner_cannot_reenter_land() {
7594 let mut state = landing_state();
7595 state.pr_taken_over_by = Some("next".into());
7596 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7597 assert!(
7598 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7599 .await
7600 .is_err()
7601 );
7602 assert!(forge.calls().is_empty());
7603 }
7604
7605 #[tokio::test]
7606 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7607 let mut state = landing_state();
7608 let forge = Scripted::new(
7609 vec![
7610 seen("old", Checks::Green, "CLEAN", true),
7611 seen("old", Checks::Green, "CLEAN", true),
7613 seen("new", Checks::Pending, "BLOCKED", true),
7614 seen("new", Checks::Green, "CLEAN", true),
7615 ],
7616 vec![(true, "")],
7617 );
7618 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7619 head: "NEW".to_owned(),
7620 });
7621 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7622 .await
7623 .unwrap();
7624 assert_eq!(
7625 forge.calls(),
7626 [
7627 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7628 "view"
7629 ]
7630 );
7631 assert_eq!(state.status, RunStatus::Merged);
7632 }
7633
7634 #[tokio::test]
7635 async fn a_head_that_never_arrives_stops_naming_both_commits() {
7636 let mut state = landing_state();
7637 let forge = Scripted::new(
7638 vec![
7639 seen("old", Checks::Green, "CLEAN", true),
7640 seen("someone-elses", Checks::Green, "CLEAN", true),
7641 ],
7642 vec![],
7643 );
7644 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7645 head: "mine".to_owned(),
7646 });
7647 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7648 .await
7649 .unwrap();
7650 assert!(!forge.calls().contains(&"merge"));
7651 let why = state.merge.as_ref().unwrap().detail.clone();
7652 assert!(
7653 why.contains("mine") && why.contains("someone-elses"),
7654 "{why}"
7655 );
7656 assert_eq!(state.status, RunStatus::Blocked);
7657 }
7658
7659 #[tokio::test]
7660 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7661 let mut state = landing_state();
7662 let forge = Scripted::new(
7663 vec![
7664 seen("a", Checks::Green, "CLEAN", false),
7665 seen("a", Checks::Green, "CLEAN", false),
7666 seen("a", Checks::Pending, "BLOCKED", false),
7667 seen("a", Checks::Pending, "BLOCKED", false),
7668 seen("a", Checks::Green, "CLEAN", false),
7669 ],
7670 vec![(false, REFUSED), (true, "")],
7671 );
7672 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7673 .await
7674 .unwrap();
7675 assert_eq!(
7676 forge.calls(),
7677 [
7678 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7679 "view"
7680 ]
7681 );
7682 assert_eq!(state.status, RunStatus::Merged);
7683 }
7684
7685 #[tokio::test]
7686 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7687 let mut state = landing_state();
7688 let forge = Scripted::new(
7689 vec![
7690 seen("a", Checks::Green, "CLEAN", false),
7691 seen("a", Checks::Green, "BLOCKED", false),
7692 ],
7693 vec![(false, REFUSED), (false, REFUSED)],
7694 );
7695 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7696 .await
7697 .unwrap();
7698 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7700 let why = state.merge.as_ref().unwrap().detail.clone();
7701 assert!(
7702 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7703 "{why}"
7704 );
7705 assert_eq!(state.status, RunStatus::Blocked);
7706 }
7707
7708 #[test]
7709 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7710 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7711 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7712 assert_eq!(bound_head("old", "old", Some("new")), None);
7714 assert_eq!(bound_head("new", "old", Some("new")), None);
7716 assert_eq!(bound_head("new", "old", None), None);
7717 assert_eq!(bound_head("", "", None), None);
7719 assert_eq!(bound_head("", "", Some("new")), None);
7720 assert_eq!(bound_head("abc", "", None), None);
7721 }
7722
7723 fn view_json(head: &str) -> String {
7724 format!(
7725 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7726 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7727 "reviews":[],"comments":[]}}"#
7728 )
7729 }
7730
7731 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7732 format!(
7733 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7734 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7735 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7736 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7737 )
7738 }
7739
7740 #[test]
7741 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7742 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7745 assert_eq!(s.rollup_head, "old");
7746 assert_eq!(s.pr.checks, Checks::Red);
7747 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7748 assert_eq!(s.failing_urls.len(), 1);
7749 }
7750
7751 #[test]
7752 fn checks_come_from_the_node_not_the_view() {
7753 let view = view_json("new").replace(
7754 r#""reviews""#,
7755 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7756 );
7757 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7758 assert_eq!(s.pr.checks, Checks::Green);
7759 assert!(s.pr.failing.is_empty());
7760 assert_eq!(
7761 bound_head(&s.head, &s.rollup_head, Some("new")),
7762 Some("new")
7763 );
7764 }
7765
7766 #[test]
7767 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7768 for node in [
7769 None,
7770 Some("not json".to_owned()),
7771 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7772 Some(node_json("new", "SUCCESS", true)),
7773 ] {
7774 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7775 assert!(s.rollup_head.is_empty());
7776 assert_eq!(s.pr.checks, Checks::Unknown);
7777 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7778 }
7779 }
7780
7781 #[test]
7782 fn the_merge_command_is_pinned_to_the_observed_head() {
7783 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7784 let at = argv
7785 .iter()
7786 .position(|a| a == "--match-head-commit")
7787 .unwrap();
7788 assert_eq!(argv[at + 1], "deadbeef");
7789 }
7790
7791 #[tokio::test]
7792 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7793 let mut state = landing_state();
7794 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7797 stale.rollup_head = "old".to_owned();
7798 let forge = Scripted::new(
7799 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7800 vec![],
7801 );
7802 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7803 head: "new".to_owned(),
7804 });
7805 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7806 .await
7807 .unwrap();
7808 assert!(!forge.calls().contains(&"merge"));
7809 assert_eq!(state.status, RunStatus::Blocked);
7810 let why = state.merge.as_ref().unwrap().detail.clone();
7811 assert!(why.contains("new") && why.contains("old"), "{why}");
7812 }
7813
7814 #[test]
7815 fn a_refusal_read_against_another_commits_checks_is_pending() {
7816 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7817 after.rollup_head = "old".to_owned();
7818 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7819 }
7820
7821 #[tokio::test]
7822 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7823 let mut state = landing_state();
7824 let forge = Scripted::new(
7825 vec![seen("a", Checks::Red, "CLEAN", false)],
7826 vec![(true, "")],
7827 );
7828 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7829 .await
7830 .unwrap();
7831 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7832 assert_eq!(state.status, RunStatus::Merged);
7833 }
7834
7835 #[tokio::test]
7836 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7837 let mut state = landing_state();
7838 let forge = Scripted::new(
7839 vec![
7840 seen("a", Checks::Green, "CLEAN", false),
7841 seen("a", Checks::Green, "CLEAN", false),
7842 seen("b", Checks::Green, "BLOCKED", false),
7844 seen("b", Checks::Green, "CLEAN", false),
7845 ],
7846 vec![(false, REFUSED), (true, "")],
7847 );
7848 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7849 .await
7850 .unwrap();
7851 assert_eq!(
7852 forge.calls(),
7853 [
7854 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7855 ]
7856 );
7857 assert_eq!(state.status, RunStatus::Merged);
7858 }
7859
7860 fn merged_view(head: &str) -> Seen {
7861 let mut m = seen(head, Checks::Green, "CLEAN", false);
7862 m.pr.state = PrLifecycle::Merged;
7863 m
7864 }
7865
7866 fn has(argv: &[String], flag: &str) -> bool {
7867 argv.iter().any(|a| a == flag)
7868 }
7869
7870 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7871 let at = argv.iter().position(|a| a == flag)?;
7872 argv.get(at + 1).map(String::as_str)
7873 }
7874
7875 const URL: &str = "https://github.com/o/r/pull/42";
7876
7877 #[tokio::test]
7878 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7879 let mut state = landing_state();
7880 let forge = Scripted::new(
7881 vec![
7882 seen("abc", Checks::Green, "CLEAN", false),
7883 seen("abc", Checks::Green, "CLEAN", false),
7884 ],
7885 vec![(true, "")],
7886 );
7887 land_with(&mut state, URL, &forge).await.unwrap();
7888 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7889 let argv = &forge.argvs()[0];
7890 assert!(has(argv, "--squash") && has(argv, "--subject"));
7891 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7892 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7893 assert_eq!(state.status, RunStatus::Merged);
7894 assert!(state.land_armed_head.is_none());
7895 }
7896
7897 #[tokio::test]
7898 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7899 let mut state = landing_state();
7900 state.land_armed_head = Some("a".to_owned());
7901 let forge = Scripted::new(
7902 vec![seen("a", Checks::Green, "CLEAN", false)],
7903 vec![(true, ""), (true, "")],
7904 );
7905 land_with(&mut state, URL, &forge).await.unwrap();
7906 let argvs = forge.argvs();
7907 assert!(has(&argvs[0], "--disable-auto"));
7908 assert!(!has(&argvs[1], "--auto"));
7909 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7910 assert_eq!(state.status, RunStatus::Merged);
7911 assert!(state.land_armed_head.is_none());
7912 }
7913
7914 #[tokio::test]
7915 async fn an_approval_never_carries_over_to_a_new_head() {
7916 crate::run::pin_test_home();
7917 let mut state = run_state();
7918 state.config.graph.land_approval = true;
7919 let pr = green_pr();
7920 let store = ask::Questions::open();
7921
7922 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7923 .await
7924 .unwrap();
7925 let mut q = store
7926 .list()
7927 .into_iter()
7928 .find(|q| q.run == state.id)
7929 .unwrap();
7930 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7931 store.put(&mut q).unwrap();
7932 assert_eq!(
7933 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7934 .await
7935 .unwrap(),
7936 ApprovalGate::Approved,
7937 "the same head keeps its approval"
7938 );
7939
7940 assert_eq!(
7942 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7943 .await
7944 .unwrap(),
7945 ApprovalGate::Pending
7946 );
7947 let all: Vec<_> = store
7948 .list()
7949 .into_iter()
7950 .filter(|q| q.run == state.id)
7951 .collect();
7952 assert_eq!(all.len(), 2);
7953
7954 state.land_approval = None;
7956 assert_eq!(
7957 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7958 .await
7959 .unwrap(),
7960 ApprovalGate::Pending
7961 );
7962 let open = store
7963 .list()
7964 .into_iter()
7965 .filter(|q| q.run == state.id && q.status.open())
7966 .count();
7967 assert_eq!(open, 1, "the superseded question was retired");
7968 }
7969
7970 #[tokio::test]
7971 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7972 let mut state = landing_state();
7973 let forge = Scripted::new(
7974 vec![
7975 seen("a", Checks::Green, "CLEAN", false),
7976 seen("b", Checks::Green, "CLEAN", false),
7978 ],
7979 vec![(true, "")],
7980 );
7981 land_with(&mut state, URL, &forge).await.unwrap();
7982 let argvs = forge.argvs();
7983 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7984 assert!(!has(&argvs[0], "--auto"));
7985 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7986 assert_eq!(state.status, RunStatus::Merged);
7987 }
7988
7989 fn passing(label: &str) -> CheckInfo {
7990 CheckInfo {
7991 label: label.to_owned(),
7992 verdict: Verdict::Pass,
7993 required: Some(false),
7994 run: None,
7995 }
7996 }
7997
7998 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7999 (label.to_owned(), run.map(str::to_owned))
8000 }
8001
8002 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8003 LandReruns {
8004 head: head.to_owned(),
8005 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8006 }
8007 }
8008
8009 #[test]
8010 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8011 let f = [failing("test (macos)", Some("7"))];
8012 assert_eq!(
8013 rerun_step(&f, None, "h", 100),
8014 RerunStep::Rerun(vec!["7".to_owned()])
8015 );
8016 let rec = reran("h", &[("7", 100)]);
8017 let grace = crate::release_watch::RERUN_GRACE;
8018 assert_eq!(
8019 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8020 RerunStep::Grace
8021 );
8022 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8023 }
8024
8025 #[test]
8026 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8027 let f = [failing("a", Some("7")), failing("ci/external", None)];
8028 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8029 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8030 }
8031
8032 #[test]
8033 fn a_moved_head_forgets_the_reruns() {
8034 let f = [failing("a", Some("7"))];
8035 let rec = reran("old", &[("7", 100)]);
8036 assert_eq!(
8037 rerun_step(&f, Some(&rec), "new", 101),
8038 RerunStep::Rerun(vec!["7".to_owned()])
8039 );
8040 }
8041
8042 #[test]
8043 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8044 let f = [
8045 failing("a", Some("7")),
8046 failing("b", Some("7")),
8047 failing("c", Some("8")),
8048 ];
8049 assert_eq!(
8050 rerun_step(&f, None, "h", 0),
8051 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8052 );
8053 let rec = reran("h", &[("7", 0)]);
8054 assert_eq!(
8055 rerun_step(&f, Some(&rec), "h", 1),
8056 RerunStep::Rerun(vec!["8".to_owned()])
8057 );
8058 }
8059
8060 #[test]
8061 fn only_blocking_failures_are_candidates_for_a_rerun() {
8062 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8063 label: label.to_owned(),
8064 verdict,
8065 required,
8066 run: Some(run.to_owned()),
8067 };
8068 let got = rerunnable(&[
8069 check("passed", Verdict::Pass, Some(true), "1"),
8070 check("optional", Verdict::Fail, Some(false), "2"),
8071 check("required", Verdict::Fail, Some(true), "3"),
8072 check("unknown", Verdict::Fail, None, "4"),
8073 ]);
8074 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8075 assert_eq!(labels, ["required", "unknown"]);
8076 }
8077
8078 fn red(head: &str, run: Option<&str>) -> Seen {
8079 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8080 s.pr.failing = vec!["test (macos)".to_owned()];
8081 s.contexts = vec![CheckInfo {
8082 label: "test (macos)".to_owned(),
8083 verdict: Verdict::Fail,
8084 required: Some(true),
8085 run: run.map(str::to_owned),
8086 }];
8087 s
8088 }
8089
8090 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8091
8092 #[tokio::test]
8093 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8094 let mut state = landing_state();
8095 let forge = Scripted::new(
8096 vec![
8097 red("a", Some("7")),
8098 seen("a", Checks::Pending, "BLOCKED", false),
8099 seen("a", Checks::Green, "CLEAN", false),
8100 ],
8101 vec![(true, "")],
8102 );
8103 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8104 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8105 assert!(!forge.calls().contains(&"fix"));
8106 assert_eq!(state.status, RunStatus::Merged);
8107 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8108 let argvs = forge.argvs();
8109 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8110 assert!(
8111 state
8112 .events
8113 .iter()
8114 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8115 );
8116 }
8117
8118 #[tokio::test]
8119 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8120 let mut state = landing_state();
8121 let forge = Scripted::new(
8122 vec![
8123 red("a", Some("7")),
8124 seen("a", Checks::Green, "CLEAN", false),
8125 ],
8126 vec![(true, "")],
8127 );
8128 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8129 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8131 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8132 let calls = forge.calls();
8133 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8134 let fix_at = calls.iter().position(|c| *c == "fix");
8135 assert!(fix_at.is_none_or(|f| f > rerun_at));
8136 }
8137
8138 #[tokio::test]
8139 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8140 let mut state = landing_state();
8141 let mut v = red("a", Some("7"));
8142 v.pr.checks = Checks::Pending;
8143 v.contexts.push(CheckInfo {
8144 label: "slow".to_owned(),
8145 verdict: Verdict::Pending,
8146 required: Some(true),
8147 run: Some("8".to_owned()),
8148 });
8149 let forge = Scripted::new(
8150 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8151 vec![(true, "")],
8152 );
8153 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8154 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8155 assert_eq!(state.status, RunStatus::Merged);
8156 }
8157
8158 #[test]
8159 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8160 let mut v = red("a", Some("7"));
8161 v.contexts.push(CheckInfo {
8162 label: "same run, still going".to_owned(),
8163 verdict: Verdict::Pending,
8164 required: Some(true),
8165 run: Some("7".to_owned()),
8166 });
8167 assert!(rerunnable(&v.contexts).is_empty());
8168 }
8169
8170 #[tokio::test]
8171 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8172 let mut state = landing_state();
8173 state.config.graph.land_rounds = 0;
8174 let forge = Scripted::new(
8175 vec![
8176 red("a", Some("7")),
8177 seen("a", Checks::Green, "CLEAN", false),
8178 ],
8179 vec![(true, "")],
8180 );
8181 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8182 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8183 assert_eq!(state.status, RunStatus::Merged);
8184 assert!(!forge.calls().contains(&"fix"));
8185 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8186 }
8187
8188 #[tokio::test]
8189 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8190 let mut state = landing_state();
8191 state.config.graph.land_rounds = 1;
8192 let forge = Scripted::new(
8193 vec![
8194 seen("a", Checks::Green, "CLEAN", true),
8195 red("b", Some("7")),
8196 seen("b", Checks::Green, "CLEAN", false),
8197 ],
8198 vec![(true, "")],
8199 );
8200 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8201 head: "b".to_owned(),
8202 });
8203 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8204 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8205 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8206 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8207 assert_eq!(state.status, RunStatus::Merged);
8208 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8209 assert_eq!(
8210 value_of(&forge.argvs()[0], "--match-head-commit"),
8211 Some("b")
8212 );
8213 }
8214
8215 #[tokio::test]
8216 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8217 let mut state = landing_state();
8218 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8219 *forge.rerun_fails.lock().unwrap() = true;
8220 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8221 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8222 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8223 assert_eq!(
8224 forge.calls().iter().filter(|c| **c == "fix").count(),
8225 1,
8226 "{:?}",
8227 forge.calls()
8228 );
8229 }
8230
8231 #[tokio::test]
8232 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8233 let mut state = landing_state();
8234 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8235 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8237 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8238 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8239 assert!(forge.reruns.lock().unwrap().is_empty());
8240 }
8241
8242 #[tokio::test]
8243 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8244 let mut state = landing_state();
8245 state.config.graph.rerun_flaky_checks = false;
8246 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8247 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8248 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8249 assert!(forge.reruns.lock().unwrap().is_empty());
8250 assert!(forge.calls().contains(&"fix"));
8251
8252 let mut state = landing_state();
8253 let forge = Scripted::new(vec![red("a", None)], vec![]);
8254 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8255 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8256 assert!(forge.reruns.lock().unwrap().is_empty());
8257 assert!(forge.calls().contains(&"fix"));
8258 }
8259
8260 fn names(xs: &[&str]) -> BTreeSet<String> {
8261 xs.iter().map(|x| (*x).to_owned()).collect()
8262 }
8263
8264 #[test]
8265 fn a_required_check_the_rollup_never_listed_is_named() {
8266 let req = names(&["build"]);
8267 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8268 assert!(why.contains("never reported: build"), "{why}");
8269 assert!(!why.contains("probably waiting for a review"), "{why}");
8270 }
8271
8272 #[test]
8273 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8274 let why = waiting_on("BLOCKED", &[passing("review")], None);
8275 assert!(why.contains("could not be read"), "{why}");
8276 assert!(!why.contains("probably waiting for a review"), "{why}");
8277 }
8278
8279 #[test]
8280 fn all_required_reported_keeps_the_review_guess() {
8281 let req = names(&["build"]);
8282 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8283 assert!(why.contains("probably waiting for a review"), "{why}");
8284 assert!(!why.contains("never reported"), "{why}");
8285 }
8286
8287 #[test]
8288 fn required_names_match_the_rollup_ignoring_case_only() {
8289 let req = names(&["Build"]);
8290 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8291 assert!(!why.contains("never reported"), "{why}");
8292 }
8293
8294 #[test]
8295 fn required_contexts_are_read_from_protection_and_rulesets() {
8296 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8297 assert_eq!(
8298 parse_classic_required(classic),
8299 Some(names(&["build", "lint"]))
8300 );
8301 let rules = r#"[{"type":"pull_request","parameters":{}},
8302 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8303 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8304 assert_eq!(parse_ruleset_required("nope"), None);
8305 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8306 }
8307
8308 #[test]
8309 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8310 let shown = BTreeSet::new();
8311 let ok = seen("a", Checks::Green, "CLEAN", false);
8312 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8313 assert!(guard(Some(&ok)));
8314 assert!(!guard(None));
8315 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
8316 let mut stale = ok.clone();
8317 stale.rollup_head = "old".to_owned();
8318 assert!(!guard(Some(&stale)));
8319 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
8320 assert!(!guard(Some(&merged_view("a"))));
8321 }
8322
8323 #[test]
8324 fn the_rollup_node_carries_whether_each_check_is_required() {
8325 let node = node_json("new", "SUCCESS", false)
8326 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
8327 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
8328 assert_eq!(s.contexts.len(), 1);
8329 assert_eq!(s.contexts[0].required, Some(true));
8330 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
8331 assert_eq!(s.contexts[0].required, None);
8332 }
8333
8334 #[tokio::test]
8335 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
8336 let mut state = landing_state();
8337 state.land_armed_head = Some("a".to_owned());
8338 let forge = Scripted::new(
8339 vec![seen("a", Checks::Green, "CLEAN", true)],
8340 vec![(false, "disable exploded")],
8341 );
8342 land_with(&mut state, URL, &forge).await.unwrap();
8343 assert!(!forge.calls().contains(&"fix"));
8344 assert_eq!(state.status, RunStatus::Blocked);
8345 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
8346 let why = state.merge.as_ref().unwrap().detail.clone();
8347 assert!(why.contains("disable exploded"), "{why}");
8348 }
8349}