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