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 let details = crate::notices::format_list(&pr.failing)
3046 .map(|names| format!(": {names}"))
3047 .unwrap_or_default();
3048 format!(
3049 "Merged {repo_name} PR #{} with red checks{details} ({})",
3050 pr.number, pr.url
3051 )
3052 })
3053}
3054
3055async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3059 let repo_name = repo_label(&state.repo, &pr.url);
3060 let Some(summary) = red_merge_summary(&repo_name, pr) else {
3061 return;
3062 };
3063 if let Some(rec) = state.pr.as_mut() {
3064 rec.red_at_merge = pr.failing.clone();
3065 }
3066 state.event("land", summary.clone());
3067 crate::notices::raise_with(
3070 crate::notices::merged_red(&state.id, &summary),
3071 &state.config.notify,
3072 );
3073}
3074
3075pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3082 land_with(state, pr_url, &GhForge).await
3083}
3084
3085trait Forge {
3089 async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3090 let (ok, out) = gh(
3091 repo,
3092 &[
3093 "pr".into(),
3094 "close".into(),
3095 pr_url.into(),
3096 "--comment".into(),
3097 comment.into(),
3098 ],
3099 )
3100 .await?;
3101 if !ok {
3102 bail!("PR closure failed: {out}");
3103 }
3104 Ok(())
3105 }
3106 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3107 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3108 async fn poll(&self);
3109 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3111 fn now(&self) -> i64;
3113 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3116 #[allow(clippy::too_many_arguments)]
3117 async fn fix(
3118 &self,
3119 state: &mut RunState,
3120 pr: &PrState,
3121 round: usize,
3122 budget: usize,
3123 reason: &str,
3124 logs: &str,
3125 ) -> Result<Fixed>;
3126}
3127
3128struct GhForge;
3129
3130impl Forge for GhForge {
3131 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3132 observe(repo, pr_url).await
3133 }
3134 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3135 gh(repo, argv).await
3136 }
3137 async fn poll(&self) {
3138 tokio::time::sleep(POLL).await;
3139 }
3140 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3141 let args = [
3142 "run".to_owned(),
3143 "rerun".to_owned(),
3144 run.to_owned(),
3145 "--failed".to_owned(),
3146 ];
3147 let (ok, out) = gh(repo, &args).await?;
3148 if !ok {
3149 bail!("gh run rerun {run}: {out}");
3150 }
3151 Ok(())
3152 }
3153 fn now(&self) -> i64 {
3154 jiff::Timestamp::now().as_second()
3155 }
3156 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3157 required_contexts_of(repo, base).await
3158 }
3159 async fn fix(
3160 &self,
3161 state: &mut RunState,
3162 pr: &PrState,
3163 round: usize,
3164 budget: usize,
3165 reason: &str,
3166 logs: &str,
3167 ) -> Result<Fixed> {
3168 fix_round(state, pr, round, budget, reason, logs).await
3169 }
3170}
3171
3172fn encode_path_segment(s: &str) -> String {
3174 let mut out = String::new();
3175 for b in s.bytes() {
3176 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3177 out.push(b as char);
3178 } else {
3179 let _ = write!(out, "%{b:02X}");
3180 }
3181 }
3182 out
3183}
3184
3185async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3190 let enc = encode_path_segment(base);
3191 let mut all = BTreeSet::new();
3192 let classic = gh(
3195 repo,
3196 &[
3197 "api".to_owned(),
3198 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3199 ],
3200 )
3201 .await
3202 .ok()?;
3203 if classic.0 {
3204 all.extend(parse_classic_required(&classic.1)?);
3205 } else if !classic.1.contains("404") {
3206 return None;
3207 }
3208 let rules = gh(
3209 repo,
3210 &[
3211 "api".to_owned(),
3212 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3213 ],
3214 )
3215 .await
3216 .ok()?;
3217 if !rules.0 {
3218 return None;
3219 }
3220 all.extend(parse_ruleset_required(&rules.1)?);
3221 Some(all)
3222}
3223
3224fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3227 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3228 let mut out = BTreeSet::new();
3229 for c in v.get("contexts")?.as_array()? {
3230 out.insert(c.as_str()?.to_owned());
3231 }
3232 for c in v
3233 .get("checks")
3234 .and_then(|c| c.as_array())
3235 .into_iter()
3236 .flatten()
3237 {
3238 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3239 out.insert(name.to_owned());
3240 }
3241 }
3242 Some(out)
3243}
3244
3245fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3247 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3248 let mut out = BTreeSet::new();
3249 for rule in v.as_array()? {
3250 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3251 continue;
3252 }
3253 let checks = rule
3254 .pointer("/parameters/required_status_checks")?
3255 .as_array()?;
3256 for c in checks {
3257 out.insert(c.get("context")?.as_str()?.to_owned());
3258 }
3259 }
3260 Some(out)
3261}
3262
3263fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3271 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3272}
3273
3274fn bound_head<'a>(
3284 seen_head: &'a str,
3285 rollup_head: &str,
3286 awaiting: Option<&str>,
3287) -> Option<&'a str> {
3288 if seen_head.is_empty()
3289 || awaiting_new_head(awaiting, seen_head)
3290 || !rollup_head.eq_ignore_ascii_case(seen_head)
3291 {
3292 return None;
3293 }
3294 Some(seen_head)
3295}
3296
3297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3299enum Refused {
3300 Pending,
3302 Recheck,
3306 Final,
3309}
3310
3311fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3314 let Some(after) = after else {
3315 return Refused::Pending;
3317 };
3318 if after.pr.state != PrLifecycle::Open {
3319 return Refused::Final;
3320 }
3321 if !after.head.eq_ignore_ascii_case(observed_head) {
3324 return Refused::Pending;
3325 }
3326 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3328 return Refused::Pending;
3329 }
3330 let state = after.merge_state.to_ascii_uppercase();
3331 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3332 || state.is_empty()
3333 || state == "UNKNOWN"
3334 {
3335 return Refused::Pending;
3336 }
3337 if rechecked {
3338 Refused::Final
3339 } else {
3340 Refused::Recheck
3341 }
3342}
3343
3344async fn disarm<F: Forge>(
3349 forge: &F,
3350 state: &mut RunState,
3351 repo: &Path,
3352 number: u64,
3353) -> std::result::Result<(), String> {
3354 let argv = disable_automerge_argv(number);
3355 let out = {
3356 let merge_lock = repo_merge_lock(repo);
3357 let _merge_slot = merge_lock.lock().await;
3358 forge.merge(repo, &argv).await
3359 };
3360 match out {
3361 Ok((true, _)) => {
3362 state.land_armed_head = None;
3363 state.event("land", "auto-merge disabled");
3364 state.save().map_err(|e| format!("{e:#}"))?;
3365 Ok(())
3366 }
3367 Ok((false, msg)) => Err(msg),
3368 Err(e) => Err(format!("{e:#}")),
3369 }
3370}
3371
3372async fn stop_disarmed<F: Forge>(
3376 forge: &F,
3377 state: &mut RunState,
3378 repo: &Path,
3379 pr: &PrState,
3380 why: &str,
3381) -> Result<()> {
3382 if state.land_armed_head.is_none() {
3383 return stop(state, repo, pr, why).await;
3384 }
3385 match disarm(forge, state, repo, pr.number).await {
3386 Ok(()) => stop(state, repo, pr, why).await,
3387 Err(e) => {
3388 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3389 stop(state, repo, pr, &why).await
3390 }
3391 }
3392}
3393
3394fn owner_requested_stop(state: &RunState) -> Result<bool> {
3396 let Some(binding) = &state.land_approval else {
3397 return Ok(false);
3398 };
3399 let q = ask::Questions::open().get(&binding.question)?;
3400 Ok(q.run == state.id
3401 && q.node == APPROVAL_NODE
3402 && q.resolution()
3403 .as_deref()
3404 .is_some_and(crate::merge_decision::stops))
3405}
3406
3407async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3410 use crate::merge_decision as md;
3411 let Some(binding) = &state.land_approval else {
3412 return Ok(false);
3413 };
3414 let q = ask::Questions::open().get(&binding.question)?;
3415 let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3416 return Ok(false);
3417 };
3418 if q.run != state.id || q.node != APPROVAL_NODE {
3419 bail!("stop decision names another run");
3420 }
3421 let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3422 pr: pr.url.clone(),
3423 head: binding.head.clone(),
3424 language: state.config.graph.language.clone(),
3425 });
3426 if target.pr != pr.url {
3427 bail!("stop decision names another PR");
3428 }
3429 if state.merge_decision.is_none() {
3430 state.merge_decision = Some(md::Decision {
3431 question: q.id.clone(),
3432 target,
3433 choice: choice.clone(),
3434 closed: false,
3435 replacement_run: None,
3436 replacement_pr: None,
3437 });
3438 state.save()?;
3439 }
3440 let repo = state.repo.clone();
3441 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3443 stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3444 return Ok(true);
3445 }
3446 if choice.eq_ignore_ascii_case(HOLD) {
3447 stop(
3448 state,
3449 &repo,
3450 pr,
3451 "owner held the decision: PR left open; no further repair or merge work",
3452 )
3453 .await?;
3454 return Ok(true);
3455 }
3456 if md::selected_agent(&choice).is_some()
3457 || state
3458 .origin
3459 .as_ref()
3460 .and_then(|o| o.task.as_ref())
3461 .is_some()
3462 {
3463 let owners: Vec<_> = crate::queue::Queue::open()
3464 .list()
3465 .into_iter()
3466 .filter(|task| task.runs.last() == Some(&state.id))
3467 .collect();
3468 let owner = (owners.len() == 1).then(|| &owners[0]);
3469 if !owner.is_some_and(|task| {
3470 task.runs.last() == Some(&state.id)
3471 && (!task
3472 .operator_decided_at
3473 .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3474 || task.resumption.as_ref().is_some_and(|release| {
3475 release.question_id.as_deref() == Some(q.id.as_str())
3476 && task.operator_decided_at == Some(release.at)
3477 }))
3478 }) {
3479 stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3480 return Ok(true);
3481 }
3482 }
3483 if pr.state == PrLifecycle::Merged {
3484 stop(
3485 state,
3486 &repo,
3487 pr,
3488 "PR already merged; withdrawal/replacement cannot be applied",
3489 )
3490 .await?;
3491 return Ok(true);
3492 }
3493 let comment = format!(
3494 "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3495 q.id, choice, state.id
3496 );
3497 if pr.state == PrLifecycle::Open {
3498 if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3499 stop(
3500 state,
3501 &repo,
3502 pr,
3503 &format!(
3504 "owner requested closure but it failed: {e}; task held, no replacement started"
3505 ),
3506 )
3507 .await?;
3508 return Ok(true);
3509 }
3510 }
3511 let confirmed = forge.view(&repo, &pr.url).await?;
3512 if confirmed.pr.state != PrLifecycle::Closed {
3513 stop(
3514 state,
3515 &repo,
3516 pr,
3517 "PR closure was not confirmed; task held, no replacement started",
3518 )
3519 .await?;
3520 return Ok(true);
3521 }
3522 *pr = confirmed.pr;
3523 state.merge_decision.as_mut().unwrap().closed = true;
3524 if let Some(record) = &mut state.pr {
3525 record.state = "closed".into();
3526 }
3527 state.status = RunStatus::Superseded;
3528 state.merge = Some(MergeOutcome {
3529 mode: MergeMode::Pr,
3530 ok: false,
3531 detail: format!("owner decision: {choice}; PR closure confirmed"),
3532 empty: false,
3533 });
3534 state.event(
3535 "land",
3536 format!(
3537 "{} applied: PR closure confirmed; old execution retired",
3538 choice
3539 ),
3540 );
3541 state.save()?;
3542 Ok(true)
3543}
3544
3545async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3546 crate::pr_head::ensure_owner(state)?;
3547 let reopening_hold = crate::merge_decision::resumable_hold(state);
3548 let repo = state.repo.clone();
3549 let budget = state.config.graph.land_rounds;
3550 let mut round = 0usize;
3551 let mut rebases = 0usize;
3554 let mut waited = Duration::ZERO;
3555 let mut shown: BTreeSet<String> = BTreeSet::new();
3560 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3562 let mut rechecked = false;
3564
3565 state.status = RunStatus::Landing;
3573 state.event("land", format!("watching {pr_url}"));
3574 state.save()?;
3575
3576 let mut resumed_armed = state.land_armed_head.is_some();
3580
3581 loop {
3582 let seen = forge.view(&repo, pr_url).await?;
3583 let mut pr = seen.pr.clone();
3584 pr.review_comments.retain(|c| !shown.contains(&c.body));
3585 state.pr = Some(crate::run::PrRecord {
3586 url: pr.url.clone(),
3587 number: pr.number,
3588 state: pr.state.as_str().to_owned(),
3589 checks: pr.checks.as_str().to_owned(),
3590 round,
3591 rounds: budget,
3592 red_at_merge: Vec::new(),
3593 });
3594 state.save()?;
3595
3596 crate::pr_head::ensure_owner(state)?;
3597 if reopening_hold {
3598 anyhow::ensure!(
3599 pr.state == PrLifecycle::Open,
3600 "held PR is no longer open; cannot request a fresh decision"
3601 );
3602 disarm(forge, state, &repo, pr.number)
3605 .await
3606 .map_err(anyhow::Error::msg)?;
3607 let previous = state.merge_decision.take();
3608 let binding = state.land_approval.take();
3609 state.config.graph.land_approval = true;
3610 let subject = merge_subject(
3611 crate::graph::landing_title(state, &seen.title),
3612 &crate::graph::landing_subject_source(state),
3613 );
3614 let contested = contested_to_ask(state);
3615 if let Err(error) =
3616 approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
3617 {
3618 state.merge_decision = previous;
3619 state.land_approval = binding;
3620 return Err(error);
3621 }
3622 state.end_pass_parked(crate::run::ParkCause::Question);
3623 state.parked = true;
3624 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()));
3625 state.save()?;
3626 return Ok(pr);
3627 }
3628 if owner_stop(state, &mut pr, forge).await? {
3629 return Ok(pr);
3630 }
3631
3632 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3633 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3638 let why = format!(
3639 "a previous pass may have armed auto-merge and it could not be disabled \
3640 on resume: {e}"
3641 );
3642 stop(state, &repo, &pr, &why).await?;
3643 return Ok(pr);
3644 }
3645 }
3646
3647 if pr.state == PrLifecycle::Open
3652 && !seen.head.is_empty()
3653 && state
3654 .land_armed_head
3655 .as_deref()
3656 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3657 {
3658 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3659 let why = format!(
3660 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3661 );
3662 stop(state, &repo, &pr, &why).await?;
3663 return Ok(pr);
3664 }
3665 }
3666
3667 if pr.state == PrLifecycle::Open {
3668 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3669 if waited >= WAIT_CEILING {
3670 let want = awaiting_head.as_deref().unwrap_or_default();
3671 let why = format!(
3672 "the pull request's checks were still not about one readable head after \
3673 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3674 someone may have pushed over it",
3675 WAIT_CEILING.as_secs() / 60,
3676 if want.is_empty() { "any" } else { want },
3677 if seen.head.is_empty() {
3678 "nothing readable"
3679 } else {
3680 &seen.head
3681 },
3682 if seen.rollup_head.is_empty() {
3683 "nothing readable"
3684 } else {
3685 &seen.rollup_head
3686 },
3687 );
3688 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3689 return Ok(pr);
3690 }
3691 waited += POLL;
3692 forge.poll().await;
3693 continue;
3694 }
3695 if awaiting_head.take().is_some() {
3698 waited = Duration::ZERO;
3701 }
3702 }
3703
3704 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3709 let failing = rerunnable(&seen.contexts);
3710 let now = forge.now();
3711 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3712 RerunStep::Rerun(runs) => {
3713 let rec = state.land_reruns.get_or_insert_with(Default::default);
3714 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3715 *rec = LandReruns {
3716 head: seen.head.clone(),
3717 runs: BTreeMap::new(),
3718 };
3719 }
3720 for run in &runs {
3721 rec.runs.insert(run.clone(), now);
3722 }
3723 state.save()?;
3727 let mut any = false;
3728 for run in &runs {
3729 let names = failing
3730 .iter()
3731 .filter(|(_, r)| r.as_deref() == Some(run))
3732 .map(|(l, _)| l.as_str())
3733 .collect::<Vec<_>>()
3734 .join(", ");
3735 match forge.rerun(&repo, run).await {
3736 Ok(()) => {
3737 any = true;
3738 state.event(
3739 "land",
3740 format!("reran failed check(s) {names} (run {run})"),
3741 );
3742 }
3743 Err(e) => state.event(
3745 "land",
3746 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3747 ),
3748 }
3749 }
3750 state.save()?;
3751 if any {
3752 waited = Duration::ZERO;
3753 forge.poll().await;
3754 continue;
3755 }
3756 }
3757 RerunStep::Grace => {
3758 if waited >= WAIT_CEILING {
3759 let why = format!(
3760 "checks were still failing after a rerun and {} minutes",
3761 WAIT_CEILING.as_secs() / 60
3762 );
3763 stop(state, &repo, &pr, &why).await?;
3764 return Ok(pr);
3765 }
3766 waited += POLL;
3767 forge.poll().await;
3768 continue;
3769 }
3770 RerunStep::Fix => {}
3771 }
3772 }
3773
3774 let step = decide(&pr, round, budget, waited);
3775 let armed_here = state
3780 .land_armed_head
3781 .as_deref()
3782 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3783 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3784 if waited >= WAIT_CEILING {
3785 let required = if seen.base.is_empty() {
3786 None
3787 } else {
3788 forge.required_contexts(&repo, &seen.base).await
3789 };
3790 let why = format!(
3791 "auto-merge was armed on {} but the pull request did not merge within {} \
3792 minutes ({})",
3793 seen.head,
3794 WAIT_CEILING.as_secs() / 60,
3795 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3796 );
3797 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3798 return Ok(pr);
3799 }
3800 waited += POLL;
3801 forge.poll().await;
3802 continue;
3803 }
3804 if armed_here && !matches!(step, Step::Done { .. }) {
3805 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3808 let why = format!("auto-merge could not be disabled: {e}");
3809 stop(state, &repo, &pr, &why).await?;
3810 return Ok(pr);
3811 }
3812 }
3813 match step {
3814 Step::Wait => {
3815 if waited >= WAIT_CEILING {
3816 let why = format!(
3817 "checks were still running after {} minutes",
3818 WAIT_CEILING.as_secs() / 60
3819 );
3820 stop(state, &repo, &pr, &why).await?;
3821 return Ok(pr);
3822 }
3823 waited += POLL;
3824 forge.poll().await;
3825 }
3826 Step::Done { merged } => {
3827 if let Some(armed) = state.land_armed_head.take() {
3832 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3833 let msg = format!(
3834 "{} merged on {} but the owner approved {armed}; review what landed",
3835 pr.url, seen.head
3836 );
3837 tracing::warn!("{msg}");
3838 state.event("land", msg);
3839 crate::notices::raise_with(
3843 crate::notices::Notice::warn(
3844 &format!("merged-unapproved-head:{}", state.id),
3845 "A pull request merged on a commit the owner did not approve; \
3846 review what landed",
3847 )
3848 .link(crate::notices::Link::Run {
3849 id: state.id.clone(),
3850 }),
3851 &state.config.notify,
3852 );
3853 }
3854 }
3855 state.status = if merged {
3856 RunStatus::Merged
3857 } else {
3858 RunStatus::Ready
3859 };
3860 let detail = if merged {
3861 format!("{} was merged", pr.url)
3862 } else {
3863 format!("{} was closed without merging", pr.url)
3864 };
3865 state.merge = Some(MergeOutcome {
3866 mode: MergeMode::Pr,
3867 ok: merged,
3868 detail: detail.clone(),
3869 empty: false,
3870 });
3871 state.event("land", detail);
3872 state.save()?;
3873 write_pr_state_through(state, pr.state);
3874 return Ok(pr);
3875 }
3876 Step::Merge => {
3877 let subject = merge_subject(
3878 crate::graph::landing_title(state, &seen.title),
3879 &crate::graph::landing_subject_source(state),
3880 );
3881 let contested = contested_to_ask(state);
3888 if state.config.graph.land_approval
3889 || contested.is_some()
3890 || repair_available(state)
3891 || !state.merge_repairs.is_empty()
3892 {
3893 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3894 .await?
3895 {
3896 ApprovalGate::Approved => {}
3897 ApprovalGate::Repair => {
3898 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3901 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3902 state.save()?;
3903 }
3904 return Ok(pr);
3905 }
3906 ApprovalGate::Held => {
3907 if owner_stop(state, &mut pr, forge).await? {
3910 return Ok(pr);
3911 }
3912 stop(
3913 state,
3914 &repo,
3915 &pr,
3916 "the owner did not approve the merge (held or unanswered)",
3917 )
3918 .await?;
3919 return Ok(pr);
3920 }
3921 ApprovalGate::Pending => {
3929 state.end_pass_parked(crate::run::ParkCause::Question);
3930 state.parked = true;
3931 state.event(
3932 "land",
3933 "parked awaiting merge approval - resumes once answered",
3934 );
3935 state.save()?;
3936 return Ok(pr);
3937 }
3938 }
3939 }
3940 let observed_head = seen.head.clone();
3951 {
3954 let fresh = forge.view(&repo, pr_url).await.ok();
3955 if !direct_merge_is_safe(
3956 fresh.as_ref(),
3957 &observed_head,
3958 &shown,
3959 round,
3960 budget,
3961 waited,
3962 ) {
3963 if waited >= WAIT_CEILING {
3964 let why = "the pull request did not settle on the approved head \
3965 before it could be merged";
3966 stop(state, &repo, &pr, why).await?;
3967 return Ok(pr);
3968 }
3969 state.event(
3970 "land",
3971 "the pull request changed before merging; looking again",
3972 );
3973 waited += POLL;
3974 forge.poll().await;
3975 continue;
3976 }
3977 }
3978 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3979 let out = {
3980 let merge_lock = repo_merge_lock(&repo);
3981 let _merge_slot = merge_lock.lock().await;
3982 crate::pr_head::ensure_owner(state)?;
3983 if let Some(task_id) = state
3984 .origin
3985 .as_ref()
3986 .and_then(|origin| origin.task.as_ref())
3987 {
3988 let task = crate::queue::Queue::open()
3989 .get(task_id)
3990 .context("cannot verify the task owner immediately before merge")?;
3991 if task.runs.last() != Some(&state.id)
3992 || task.operator_held()
3993 || task.status == crate::queue::TaskStatus::Done
3994 {
3995 bail!(
3996 "task ownership or an operator hold superseded this merge approval"
3997 );
3998 }
3999 }
4000 if let Some(binding) = &state.land_approval {
4001 let current = ask::Questions::open().get(&binding.question)?;
4002 if merge_consent_superseded(state, ¤t)?
4003 || current.run != state.id
4004 || !binding.head.eq_ignore_ascii_case(&observed_head)
4005 || approval(current.resolution().as_deref()) != Approval::Merge
4006 || current.merge_target.as_ref().is_some_and(|t| {
4007 t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4008 })
4009 {
4010 bail!(
4011 "merge decision is stale or no longer authorizes the reviewed head"
4012 );
4013 }
4014 }
4015 forge.merge(&repo, &argv).await?
4016 };
4017 if out.0 {
4018 let confirmed = forge
4024 .view(&repo, pr_url)
4025 .await
4026 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
4027 if !confirmed {
4028 if waited >= WAIT_CEILING {
4029 let why = "the merge request succeeded but the pull request \
4030 could not be confirmed merged after waiting";
4031 stop(state, &repo, &pr, why).await?;
4032 return Ok(pr);
4033 }
4034 state.event(
4035 "land",
4036 "merge accepted but the pull request is not confirmed merged yet; waiting",
4037 );
4038 state.save()?;
4039 waited += POLL;
4040 forge.poll().await;
4041 continue;
4042 }
4043 pr.state = PrLifecycle::Merged;
4044 state.status = RunStatus::Merged;
4045 state.merge = Some(MergeOutcome {
4046 mode: MergeMode::Pr,
4047 ok: true,
4048 detail: format!("gh {}", argv.join(" ")),
4049 empty: false,
4050 });
4051 if let Some(pr_record) = state.pr.as_mut() {
4056 pr_record.state = pr.state.as_str().to_owned();
4057 }
4058 state.event("land", format!("merged {} as `{subject}`", pr.url));
4059 announce_red_merge(state, &pr).await;
4060 state.save()?;
4061 write_pr_state_through(state, pr.state);
4062 return Ok(pr);
4063 }
4064 let after_seen = forge.view(&repo, pr_url).await.ok();
4065 let after = after_seen.as_ref().map(|s| s.pr.state);
4066 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4067 pr.state = PrLifecycle::Merged;
4068 state.status = RunStatus::Merged;
4069 state.merge = Some(outcome);
4070 if let Some(pr_record) = state.pr.as_mut() {
4071 pr_record.state = pr.state.as_str().to_owned();
4072 }
4073 state.event("land", format!("merged {} as `{subject}`", pr.url));
4074 announce_red_merge(state, &pr).await;
4075 state.save()?;
4076 write_pr_state_through(state, pr.state);
4077 return Ok(pr);
4078 }
4079 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4080 match verdict {
4081 Refused::Final => {
4082 let merge_state = after_seen
4083 .as_ref()
4084 .map(|s| s.merge_state.as_str())
4085 .filter(|m| !m.is_empty())
4086 .unwrap_or("unknown");
4087 let why = format!(
4091 "the merge was refused: {} (merge state: {merge_state})",
4092 out.1
4093 );
4094 stop(state, &repo, &pr, &why).await?;
4095 return Ok(pr);
4096 }
4097 verdict => {
4098 if waited >= WAIT_CEILING {
4103 let why = format!(
4104 "the merge was still refused after {} minutes: {}",
4105 WAIT_CEILING.as_secs() / 60,
4106 out.1
4107 );
4108 stop(state, &repo, &pr, &why).await?;
4109 return Ok(pr);
4110 }
4111 if verdict == Refused::Recheck {
4112 rechecked = true;
4113 }
4114 state.event(
4115 "land",
4116 "merge refused while the branch policy is not satisfied yet; waiting",
4117 );
4118 state.save()?;
4119 waited += POLL;
4120 forge.poll().await;
4121 }
4122 }
4123 }
4124 Step::Rebase => {
4125 if rebases >= budget {
4131 let why = format!(
4132 "the base moved under this branch {budget} time(s) and it still does \
4133 not merge; rebasing again would only race it"
4134 );
4135 stop(state, &repo, &pr, &why).await?;
4136 return Ok(pr);
4137 }
4138 rebases += 1;
4139 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4140 stop(
4141 state,
4142 &repo,
4143 &pr,
4144 "the pull request conflicts and this run has no winning branch to rebase",
4145 )
4146 .await?;
4147 return Ok(pr);
4148 };
4149 let base = state.base_branch.clone();
4150 state.event(
4151 "land",
4152 format!("{} no longer merges; rebasing onto {base}", pr.url),
4153 );
4154 state.save()?;
4155
4156 let remote = state.config.merge.remote.clone();
4160 git::fetch(&repo, &remote, &base).await.ok();
4161 if owner_stop(state, &mut pr, forge).await? {
4162 return Ok(pr);
4163 }
4164 let scratch = state.dir().join("rebase");
4165 let onto = format!("{remote}/{base}");
4166 let rebased =
4167 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
4168 Ok(crate::rebase::Rebased::Applied) => Ok(None),
4169 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4170 Err(e) => Err(e),
4171 };
4172 if owner_stop(state, &mut pr, forge).await? {
4173 return Ok(pr);
4174 }
4175 match rebased {
4176 Ok(None) => {
4177 let pushed = {
4178 let merge_lock = repo_merge_lock(&repo);
4179 let _merge_slot = merge_lock.lock().await;
4180 if let Some(head) = &state.pr_head {
4181 git::push_as(
4182 &repo,
4183 &state.config.merge.remote,
4184 &branch,
4185 head,
4186 state
4187 .pr_pushed_head
4188 .as_deref()
4189 .context("missing public head lease")?,
4190 )
4191 .await?
4192 } else {
4193 git::push_pinned(
4194 &repo,
4195 &state.config.merge.remote,
4196 &branch,
4197 &seen.head,
4198 )
4199 .await?
4200 }
4201 };
4202 if !pushed.ok() {
4203 let why = format!(
4204 "rebased {branch} but could not push it: {}",
4205 pushed.stderr.trim()
4206 );
4207 stop(state, &repo, &pr, &why).await?;
4208 return Ok(pr);
4209 }
4210 let head =
4213 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
4214 Ok(head) => head,
4215 Err(e) => {
4216 let why = format!(
4217 "rebased and pushed {branch} but could not read the pushed \
4218 commit: {e:#}"
4219 );
4220 stop(state, &repo, &pr, &why).await?;
4221 return Ok(pr);
4222 }
4223 };
4224 crate::graph::refresh_reviewed_commits(state, &branch).await;
4225 state.pr_pushed_head = Some(head.clone());
4226 awaiting_head = Some(head);
4227 rechecked = false;
4228 state.event("land", format!("rebased {branch} onto {base}"));
4229 state.save()?;
4230 waited = Duration::ZERO;
4233 tokio::time::sleep(POLL).await;
4234 }
4235 Ok(Some(conflict)) => {
4238 let why = format!(
4239 "{} conflicts with {base} and the rebase did not apply: {}",
4240 pr.url,
4241 conflict.chars().take(600).collect::<String>()
4242 );
4243 stop(state, &repo, &pr, &why).await?;
4244 return Ok(pr);
4245 }
4246 Err(e) => {
4247 let why = format!("could not rebase {branch} onto {base}: {e:#}");
4248 stop(state, &repo, &pr, &why).await?;
4249 return Ok(pr);
4250 }
4251 }
4252 }
4253 Step::GiveUp { reason } => {
4254 stop(state, &repo, &pr, &reason).await?;
4255 return Ok(pr);
4256 }
4257 Step::Fix { reason } => {
4258 round += 1;
4259 waited = Duration::ZERO;
4260 for c in &pr.review_comments {
4261 shown.insert(c.body.clone());
4262 }
4263 state.event("land", format!("round {round}: {reason}"));
4264 state.save()?;
4265
4266 let logs = failing_logs(&repo, &seen.failing_urls).await;
4267 let was_red = pr.checks == Checks::Red;
4268 if owner_stop(state, &mut pr, forge).await? {
4269 return Ok(pr);
4270 }
4271 let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4272 if owner_stop(state, &mut pr, forge).await? {
4273 return Ok(pr);
4274 }
4275 match fixed {
4276 Fixed::Committed { head } => {
4277 state.pr_pushed_head = Some(head.clone());
4280 awaiting_head = Some(head);
4281 rechecked = false;
4282 waited = Duration::ZERO;
4283 forge.poll().await;
4284 }
4285 Fixed::Declined if was_red => {
4286 let why = format!(
4287 "the fixer produced no commit while {} check(s) were failing \
4288 ({}); stopping instead of looping on an unchanged tree",
4289 pr.failing.len(),
4290 pr.failing.join(", ")
4291 );
4292 stop(state, &repo, &pr, &why).await?;
4293 return Ok(pr);
4294 }
4295 Fixed::Declined => state.event(
4300 "land",
4301 format!("round {round}: fixer declined the comments, nothing committed"),
4302 ),
4303 Fixed::Failed(why) => {
4304 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4305 return Ok(pr);
4306 }
4307 }
4308 state.save()?;
4309 }
4310 }
4311 }
4312}
4313
4314#[derive(Clone)]
4318struct Seen {
4319 pr: PrState,
4320 title: String,
4321 failing_urls: Vec<(String, String)>,
4322 head: String,
4324 rollup_head: String,
4327 merge_state: String,
4330 contexts: Vec<CheckInfo>,
4332 base: String,
4334}
4335
4336#[derive(Clone)]
4338struct CheckInfo {
4339 label: String,
4340 verdict: Verdict,
4341 required: Option<bool>,
4342 run: Option<String>,
4344}
4345
4346#[derive(Debug, Clone, PartialEq, Eq)]
4348enum RerunStep {
4349 Rerun(Vec<String>),
4351 Grace,
4353 Fix,
4355}
4356
4357fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4361 contexts
4362 .iter()
4363 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4364 .filter(|c| {
4366 c.run.as_ref().is_none_or(|r| {
4367 !contexts.iter().any(|o| {
4368 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4369 && o.run.as_ref() == Some(r)
4370 })
4371 })
4372 })
4373 .map(|c| (c.label.clone(), c.run.clone()))
4374 .collect()
4375}
4376
4377fn rerun_step(
4379 failing: &[(String, Option<String>)],
4380 record: Option<&LandReruns>,
4381 head: &str,
4382 now: i64,
4383) -> RerunStep {
4384 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4387 return RerunStep::Fix;
4388 }
4389 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4391 let mut fresh: Vec<String> = Vec::new();
4392 let mut recent = false;
4393 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4394 match record.and_then(|r| r.runs.get(run)) {
4395 None => {
4396 if !fresh.iter().any(|f| f == run) {
4397 fresh.push(run.to_owned());
4398 }
4399 }
4400 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4401 Some(_) => {}
4402 }
4403 }
4404 if !fresh.is_empty() {
4405 RerunStep::Rerun(fresh)
4406 } else if recent {
4407 RerunStep::Grace
4408 } else {
4409 RerunStep::Fix
4410 }
4411}
4412
4413async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4417 let view = gh(
4418 repo,
4419 &[
4420 "pr".to_owned(),
4421 "view".to_owned(),
4422 pr_url.to_owned(),
4423 "--json".to_owned(),
4424 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4425 .to_owned(),
4426 ],
4427 )
4428 .await?;
4429 if !view.0 {
4430 bail!("gh pr view {pr_url}: {}", view.1);
4431 }
4432 let number = parse_pr(&view.1)?.number;
4433 let node = last_commit_node(repo, number).await;
4434 let mut seen = seen_from(&view.1, node.as_deref())?;
4435
4436 let inline = gh(
4437 repo,
4438 &[
4439 "api".to_owned(),
4440 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4441 ],
4442 )
4443 .await?;
4444 if inline.0 {
4445 match parse_inline_comments(&inline.1) {
4446 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4447 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4450 }
4451 } else {
4452 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4453 }
4454 Ok(seen)
4455}
4456
4457fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4468 let mut pr = parse_pr(view_json)?;
4469 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4470
4471 let mut rollup_head = String::new();
4472 let mut failing_urls = Vec::new();
4473 let mut contexts = Vec::new();
4474 let mut checks = Checks::Unknown;
4475 let mut failing = Vec::new();
4476 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4477 (checks, failing) = rollup_verdict(&rollup);
4478 failing_urls = rollup
4479 .iter()
4480 .filter(|c| c.verdict() == Verdict::Fail)
4481 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4482 .collect();
4483 contexts = rollup
4484 .iter()
4485 .map(|c| CheckInfo {
4486 label: c.label(),
4487 verdict: c.verdict(),
4488 required: c.is_required,
4489 run: c.url().and_then(run_of),
4490 })
4491 .collect();
4492 rollup_head = oid;
4493 }
4494 pr.checks = checks;
4495 pr.failing = failing;
4496
4497 Ok(Seen {
4498 pr,
4499 title: raw.title,
4500 failing_urls,
4501 head: raw.head_ref_oid,
4502 rollup_head,
4503 merge_state: raw.merge_state_status,
4504 contexts,
4505 base: raw.base_ref_name,
4506 })
4507}
4508
4509fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4512 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4513 if v.get("errors").is_some_and(|e| !e.is_null()) {
4514 return None;
4515 }
4516 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4517 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4518 let contexts = commit.pointer("/statusCheckRollup/contexts");
4519 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4520 return Some((oid.to_owned(), Vec::new()));
4522 };
4523 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4524 return None;
4525 }
4526 let nodes = contexts.get("nodes")?.as_array()?;
4527 let rollup = nodes
4528 .iter()
4529 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4530 .collect::<Result<Vec<_>, _>>()
4531 .ok()?;
4532 Some((oid.to_owned(), rollup))
4533}
4534
4535async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4538 let out = gh(
4539 repo,
4540 &[
4541 "api".to_owned(),
4542 "graphql".to_owned(),
4543 "-F".to_owned(),
4544 "owner={owner}".to_owned(),
4545 "-F".to_owned(),
4546 "repo={repo}".to_owned(),
4547 "-F".to_owned(),
4548 format!("number={number}"),
4549 "-f".to_owned(),
4550 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4551 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4552 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4553 ... on CheckRun{name status conclusion detailsUrl \
4554 isRequired(pullRequestNumber:$number)} \
4555 ... on StatusContext{context state targetUrl \
4556 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4557 .to_owned(),
4558 ],
4559 )
4560 .await
4561 .ok()?;
4562 out.0.then_some(out.1)
4563}
4564
4565#[doc(hidden)]
4567#[derive(Debug, PartialEq)]
4568pub enum Fixed {
4569 Committed {
4571 head: String,
4573 },
4574 Declined,
4576 Failed(String),
4578}
4579
4580#[doc(hidden)]
4586pub async fn fix_round(
4587 state: &mut RunState,
4588 pr: &PrState,
4589 round: usize,
4590 budget: usize,
4591 reason: &str,
4592 logs: &str,
4593) -> Result<Fixed> {
4594 let winner = state
4595 .winner()
4596 .cloned()
4597 .context("landing needs a winning candidate; none is recorded on this run")?;
4598 let roles = state
4599 .config
4600 .resolve_roles()
4601 .context("resolve the roster for the fix round")?;
4602 let attempts = crate::fixer::attempts(state, &roles, &winner);
4606 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4607
4608 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4609 let mut artifacts = agent::artifacts_dir(&state.dir());
4610 if let Some(request) = state
4611 .merge_repairs
4612 .last()
4613 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4614 {
4615 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4616 }
4617 let prompt = if state.config.cache_dir().is_some() {
4618 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4619 } else {
4620 prompt
4621 };
4622 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4626 let mut last = None;
4630 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4631 if owner_requested_stop(state)? {
4632 return Ok(Fixed::Declined);
4633 }
4634 let mut seat = seat_of(state, &seat_key, &spec.id);
4635 let stem = if i == 0 {
4636 format!("land-{round}")
4637 } else {
4638 format!("land-{round}-{}", spec.id)
4639 };
4640 let out = agent::invoke(
4641 &spec,
4642 &mut seat,
4643 &Invocation {
4644 coordination: state.config.coordination.as_ref(),
4645 cwd: &winner.worktree,
4646 prompt: &prompt,
4647 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4648 allow_write: true,
4649 unsandboxed: false,
4650 sessions: state.config.graph.sessions,
4651 artifacts: &artifacts,
4652 stem: &stem,
4653 run: &state.id,
4654 node: "land",
4655 cache_dir: state.config.cache_dir().as_deref(),
4656 attachments: &[],
4657 writable: &[],
4658 },
4659 )
4660 .await;
4661 state.seats.insert(seat.key.clone(), seat);
4662 if let Some(next) = ids.get(i + 1)
4663 && agent::chain_advances(&out)
4664 {
4665 let (class, why) = crate::fixer::failure_of(&out);
4666 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4667 continue;
4668 }
4669 last = Some(out);
4670 break;
4671 }
4672 let out = last.expect("the fixer chain always has an entry");
4673
4674 match out {
4675 Ok(o) if o.quota_exhausted() => {
4676 return Ok(Fixed::Failed(
4677 "rate limited (quota); the fixer could not run".to_owned(),
4678 ));
4679 }
4680 Ok(o) if !o.usable() => {
4681 return Ok(Fixed::Failed(format!(
4682 "the fixer produced nothing usable ({}{})",
4683 crate::notices::exit_status(o.exit_code),
4684 if o.timed_out {
4685 "; the turn timed out"
4686 } else {
4687 ""
4688 }
4689 )));
4690 }
4691 Ok(_) => {}
4692 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4693 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4694 }
4695
4696 if owner_requested_stop(state)? {
4697 return Ok(Fixed::Declined);
4698 }
4699
4700 let wait = crate::repo_lock::WaitPolicy::new(
4703 Duration::from_secs(state.config.graph.timeout_resource_wait),
4704 || false,
4705 );
4706 let coordination = crate::repo_lock::SeatLease::operation_with(
4707 &winner.worktree,
4708 state.config.coordination.as_ref(),
4709 &wait,
4710 )
4711 .await?;
4712 match git::rescue_commit(
4713 &winner.worktree,
4714 &format!("magi: land round {round} fixes (uncommitted work)"),
4715 )
4716 .await
4717 {
4718 Ok(r) => state.note_withheld("land", &r.withheld),
4719 Err(e)
4720 if state
4721 .merge_repairs
4722 .last()
4723 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4724 {
4725 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4726 }
4727 Err(_) => {}
4728 }
4729 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4730 drop(coordination);
4731 if after == before {
4732 return Ok(Fixed::Declined);
4733 }
4734
4735 if owner_requested_stop(state)? {
4736 return Ok(Fixed::Declined);
4737 }
4738 crate::pr_head::ensure_owner(state)?;
4739 let remote = state.config.merge.remote.clone();
4740 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4741 let push = if state.pr_head.is_some() {
4742 let expected = state
4743 .pr_pushed_head
4744 .as_deref()
4745 .context("missing public head lease")?;
4746 git::push_as(
4747 &winner.worktree,
4748 &remote,
4749 &winner.branch,
4750 &public_head,
4751 expected,
4752 )
4753 .await?
4754 } else {
4755 git::push(&winner.worktree, &remote, &winner.branch).await?
4756 };
4757 if !push.ok() {
4758 return Ok(Fixed::Failed(format!(
4759 "pushing {} to {remote} failed: {}",
4760 public_head, push.stderr
4761 )));
4762 }
4763 state.event(
4764 "land",
4765 format!("round {round}: pushed a fix to {public_head}"),
4766 );
4767 state.pr_pushed_head = Some(after.clone());
4768 state.save()?;
4769 Ok(Fixed::Committed { head: after })
4770}
4771
4772pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4774 crate::fixer::seat_for(state, key, agent)
4775}
4776
4777pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4780 let seen = observe(&state.repo, url).await?;
4781 let winner = state.winner().context("no winning branch to repair")?;
4782 if seen.pr.state != PrLifecycle::Open || seen.head != head {
4783 anyhow::bail!(
4784 "PR is closed or its head changed; review the current head before requesting repair again"
4785 );
4786 }
4787 if git::rev_parse(&winner.worktree, "HEAD").await? != head
4788 || !git::is_clean(&winner.worktree).await?
4789 {
4790 anyhow::bail!(
4791 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4792 );
4793 }
4794 let view = gh(
4797 &state.repo,
4798 &[
4799 "pr".into(),
4800 "view".into(),
4801 url.into(),
4802 "--json".into(),
4803 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4804 ],
4805 )
4806 .await?;
4807 if !view.0 {
4808 bail!("cannot check auto-merge: {}", view.1);
4809 }
4810 let live: serde_json::Value = serde_json::from_str(&view.1)?;
4811 let public = state.pr_branch().unwrap_or(&winner.branch);
4812 if live["state"] != "OPEN"
4813 || live["headRefOid"] != head
4814 || live["headRefName"] != public
4815 || live["baseRefName"] != state.base_branch
4816 || live["isCrossRepository"] != false
4817 {
4818 bail!("PR changed during repair preflight; review its current head explicitly");
4819 }
4820 let auto_merge = live
4821 .get("autoMergeRequest")
4822 .context("cannot read the PR auto-merge reservation; repair held")?;
4823 if !auto_merge.is_null() {
4824 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4825 if !out.0 {
4826 bail!("cannot disable auto-merge; repair held: {}", out.1);
4827 }
4828 }
4829 Ok(seen.pr)
4830}
4831
4832fn fix_prompt(
4834 state: &RunState,
4835 pr: &PrState,
4836 round: usize,
4837 budget: usize,
4838 reason: &str,
4839 logs: &str,
4840) -> String {
4841 let mut s = format!(
4842 "Your patch is open as a pull request and it is not landing. Land round \
4843 {round} of {budget}.\n\n\
4844 Pull request: {}\n\n\
4845 What is holding it: {reason}\n\n\
4846 # The task\n\n{}\n",
4847 pr.url, state.instruction
4848 );
4849
4850 if pr.failing.is_empty() {
4851 s.push_str("\n# Failing checks\n\n(none)\n");
4852 } else {
4853 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4854 if logs.trim().is_empty() {
4855 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4856 } else {
4857 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4858 }
4859 }
4860
4861 if pr.review_comments.is_empty() {
4862 s.push_str("\n# Review comments\n\n(none)\n");
4863 } else {
4864 s.push_str("\n# Review comments\n");
4865 for c in &pr.review_comments {
4866 let where_ = match (&c.path, c.line) {
4867 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4868 (Some(p), None) => format!(" ({p})"),
4869 _ => String::new(),
4870 };
4871 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4872 }
4873 }
4874
4875 s.push_str(
4876 "\n# Rules\n\n\
4877 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4878 failing test; do not silence a lint with an allow attribute; do not \
4879 stretch a timeout to hide a race. If the check is right, the code is \
4880 wrong.\n\
4881 2. Change nothing the checks and the comments did not raise. A \
4882 drive-by refactor turns a one-line fix into a pull request that \
4883 needs reviewing again.\n\
4884 3. If a comment is wrong, say so with a checkable argument and change \
4885 nothing for it. A declined comment with a reason is a correct \
4886 outcome; a change made to appease a reviewer is not.\n\
4887 4. Commit in this worktree. magi pushes to the pull request's branch \
4888 for you; do not push, merge, or close anything yourself.\n\
4889 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4890 # Output\n\n\
4891 Say what you changed and why, and what you declined and why.",
4892 );
4893
4894 let language = &state.config.graph.language;
4895 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4896 let _ = write!(s, "\n\nWrite all prose in {language}.");
4897 }
4898 s.push_str(&crate::prompt::github_rule(
4900 language,
4901 &state.config.graph.github_language,
4902 ));
4903 if let Some(overlay) = state.config.prompts.overlay("fix") {
4904 let _ = write!(s, "\n\n{overlay}");
4905 }
4906 s
4907}
4908
4909async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4912 let mut out = String::new();
4913 for (name, url) in failing.iter().take(MAX_LOGS) {
4914 let args = match (job_of(url), run_of(url)) {
4915 (Some(job), _) => vec![
4916 "run".to_owned(),
4917 "view".to_owned(),
4918 "--log-failed".to_owned(),
4919 "--job".to_owned(),
4920 job,
4921 ],
4922 (None, Some(run)) => vec![
4923 "run".to_owned(),
4924 "view".to_owned(),
4925 run,
4926 "--log-failed".to_owned(),
4927 ],
4928 (None, None) => continue,
4930 };
4931 let (ok, body) = match gh(repo, &args).await {
4932 Ok(v) => v,
4933 Err(e) => (false, format!("{e:#}")),
4934 };
4935 if !ok && body.trim().is_empty() {
4936 continue;
4937 }
4938 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4939 }
4940 out
4941}
4942
4943fn job_of(details_url: &str) -> Option<String> {
4946 let after = details_url.split("/job/").nth(1)?;
4947 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4948 (!id.is_empty()).then_some(id)
4949}
4950
4951pub(crate) fn run_of(details_url: &str) -> Option<String> {
4953 let after = details_url.split("/actions/runs/").nth(1)?;
4954 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4955 (!id.is_empty()).then_some(id)
4956}
4957
4958fn stop_comment(run_id: &str, why: &str) -> String {
4962 format!(
4963 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4964 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4965 )
4966}
4967
4968async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4973 let body = stop_comment(&state.id, why);
4974 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4975 if !body.contains(MARKER) {
4976 body = format!("{MARKER}\n{body}");
4977 }
4978 let posted = gh(
4979 repo,
4980 &[
4981 "pr".to_owned(),
4982 "comment".to_owned(),
4983 pr.number.to_string(),
4984 "--body".to_owned(),
4985 body,
4986 ],
4987 )
4988 .await;
4989 match posted {
4990 Ok((true, _)) => {}
4991 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4992 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4993 }
4994 state.status = RunStatus::Blocked;
4995 state.merge = Some(MergeOutcome {
4996 mode: MergeMode::Pr,
4997 ok: false,
4998 detail: why.to_owned(),
4999 empty: false,
5000 });
5001 state.event("land", format!("stopped: {why}"));
5002 state.save()?;
5003 Ok(())
5004}
5005
5006pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5022 let out = tokio::process::Command::new("gh")
5023 .args(args)
5024 .current_dir(cwd)
5025 .env_remove("GH_REPO")
5026 .quiet()
5027 .stdin(std::process::Stdio::null())
5028 .output()
5029 .await
5030 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5031 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5032 let err = String::from_utf8_lossy(&out.stderr);
5033 if body.trim().is_empty() {
5034 body = err.into_owned();
5035 } else if !err.trim().is_empty() {
5036 body.push_str(&err);
5037 }
5038 Ok((out.status.success(), body.trim().to_owned()))
5039}
5040
5041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5043pub(crate) enum Verdict {
5044 Pass,
5045 Fail,
5046 Pending,
5047 Unknown,
5048}
5049
5050#[derive(Debug, Deserialize)]
5051#[serde(rename_all = "camelCase")]
5052struct GhPr {
5053 #[serde(default)]
5054 url: String,
5055 #[serde(default)]
5056 number: u64,
5057 #[serde(default)]
5058 state: String,
5059 #[serde(default)]
5060 title: String,
5061 #[serde(default)]
5062 status_check_rollup: Vec<GhCheck>,
5063 #[serde(default)]
5070 merge_state_status: String,
5071 #[serde(default)]
5075 head_ref_oid: String,
5076 #[serde(default)]
5077 base_ref_name: String,
5078 #[serde(default)]
5079 reviews: Vec<GhReview>,
5080 #[serde(default)]
5081 comments: Vec<GhComment>,
5082}
5083
5084#[derive(Debug, Deserialize)]
5089#[serde(rename_all = "camelCase")]
5090struct GhCheck {
5091 #[serde(default)]
5092 name: Option<String>,
5093 #[serde(default)]
5094 context: Option<String>,
5095 #[serde(default)]
5096 status: Option<String>,
5097 #[serde(default)]
5098 conclusion: Option<String>,
5099 #[serde(default)]
5100 state: Option<String>,
5101 #[serde(default)]
5102 details_url: Option<String>,
5103 #[serde(default)]
5104 target_url: Option<String>,
5105 #[serde(default)]
5108 is_required: Option<bool>,
5109}
5110
5111impl GhCheck {
5112 fn label(&self) -> String {
5114 self.name
5115 .clone()
5116 .or_else(|| self.context.clone())
5117 .unwrap_or_else(|| "(unnamed check)".to_owned())
5118 }
5119
5120 fn url(&self) -> Option<&str> {
5122 self.details_url
5123 .as_deref()
5124 .or(self.target_url.as_deref())
5125 .filter(|u| !u.is_empty())
5126 }
5127
5128 fn verdict(&self) -> Verdict {
5136 if let Some(status) = self.status.as_deref() {
5137 if !status.eq_ignore_ascii_case("COMPLETED") {
5138 return Verdict::Pending;
5139 }
5140 }
5141 let outcome = self
5142 .conclusion
5143 .as_deref()
5144 .or(self.state.as_deref())
5145 .unwrap_or("");
5146 match outcome.to_ascii_uppercase().as_str() {
5147 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5148 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5149 | "ACTION_REQUIRED" => Verdict::Fail,
5150 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5151 Verdict::Pending
5152 }
5153 _ => Verdict::Unknown,
5154 }
5155 }
5156}
5157
5158#[derive(Debug, Deserialize)]
5159struct GhAuthor {
5160 #[serde(default)]
5161 login: String,
5162}
5163
5164#[derive(Debug, Deserialize)]
5165struct GhReview {
5166 #[serde(default)]
5167 author: GhAuthor,
5168 #[serde(default)]
5169 body: String,
5170}
5171
5172#[derive(Debug, Deserialize)]
5173struct GhComment {
5174 #[serde(default)]
5175 author: GhAuthor,
5176 #[serde(default)]
5177 body: String,
5178}
5179
5180#[derive(Debug, Deserialize)]
5181struct GhUser {
5182 #[serde(default)]
5183 login: String,
5184}
5185
5186#[derive(Debug, Deserialize)]
5187struct GhInline {
5188 #[serde(default)]
5189 user: GhUser,
5190 #[serde(default)]
5191 path: Option<String>,
5192 #[serde(default)]
5193 line: Option<u64>,
5194 #[serde(default)]
5195 body: String,
5196}
5197
5198impl Default for GhAuthor {
5199 fn default() -> Self {
5200 Self {
5201 login: "(unknown)".to_owned(),
5202 }
5203 }
5204}
5205
5206impl Default for GhUser {
5207 fn default() -> Self {
5208 Self {
5209 login: "(unknown)".to_owned(),
5210 }
5211 }
5212}
5213
5214#[cfg(test)]
5215pub(crate) mod tests {
5216 use super::*;
5217 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5218
5219 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5220 format!(
5221 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5222 )
5223 }
5224
5225 #[test]
5226 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5227 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5228 assert_eq!(
5229 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5230 Ok(())
5231 );
5232 for (json, why) in [
5233 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5234 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5235 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5236 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5237 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5238 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5239 ] {
5240 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5241 .unwrap_err()
5242 .why;
5243 assert!(err.contains(why), "{json}: {err}");
5244 }
5245 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5247 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5248 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5249 assert!(
5250 !closable(
5251 &head_json("x", "main", "OPEN", false),
5252 "magi/27b2/A",
5253 "main",
5254 &[]
5255 )
5256 .unwrap_err()
5257 .retry
5258 );
5259 assert!(is_forge_url("https://github.com/o/r.git"));
5260 assert!(is_forge_url("git@github.com:o/r.git"));
5261 assert!(!is_forge_url("/tmp/origin.git"));
5262 assert!(!is_forge_url("C:\\work\\origin.git"));
5263 assert!(!is_forge_url("file:///tmp/origin.git"));
5264 assert!(forge_unavailable(
5265 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5266 ));
5267 assert!(!forge_unavailable(
5268 "gh pr list failed: error connecting to api.github.com"
5269 ));
5270 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5271 assert!(
5273 closable(
5274 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5275 "b",
5276 "main",
5277 &["aaa".to_owned()]
5278 )
5279 .is_err()
5280 );
5281 }
5282
5283 #[test]
5284 fn the_close_comment_names_the_commit_on_the_base() {
5285 let e = crate::already::Evidence {
5286 proof: crate::already::Proof::PatchId,
5287 tip: "1234567890".to_owned(),
5288 commits: vec!["0e368de0000".to_owned()],
5289 };
5290 let c = superseded_comment("main", &e);
5291 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5292 }
5293
5294 const GREEN_OPEN: &str = r####"{
5296 "url": "https://github.com/yukimemi/magi/pull/10",
5297 "number": 10,
5298 "state": "OPEN",
5299 "mergeStateStatus": "CLEAN",
5300 "statusCheckRollup": [
5301 {
5302 "__typename": "CheckRun",
5303 "conclusion": "SKIPPED",
5304 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5305 "name": "review",
5306 "status": "COMPLETED",
5307 "workflowName": "claude-review"
5308 },
5309 {
5310 "__typename": "CheckRun",
5311 "conclusion": "SUCCESS",
5312 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5313 "name": "check (ubuntu-latest)",
5314 "status": "COMPLETED",
5315 "workflowName": "CI"
5316 },
5317 {
5318 "__typename": "CheckRun",
5319 "conclusion": "SUCCESS",
5320 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5321 "name": "rustfmt",
5322 "status": "COMPLETED",
5323 "workflowName": "CI"
5324 },
5325 {
5326 "__typename": "StatusContext",
5327 "context": "CodeRabbit",
5328 "state": "SUCCESS",
5329 "targetUrl": ""
5330 }
5331 ],
5332 "reviews": [],
5333 "comments": [
5334 {
5335 "author": {
5336 "login": "coderabbitai"
5337 },
5338 "authorAssociation": "NONE",
5339 "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"
5340 }
5341 ]
5342}"####;
5343
5344 const RED_OPEN: &str = r####"{
5346 "url": "https://github.com/yukimemi/magi/pull/9",
5347 "number": 9,
5348 "state": "OPEN",
5349 "mergeStateStatus": "UNSTABLE",
5350 "statusCheckRollup": [
5351 {
5352 "__typename": "CheckRun",
5353 "conclusion": "SUCCESS",
5354 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5355 "name": "check (ubuntu-latest)",
5356 "status": "COMPLETED",
5357 "workflowName": "CI"
5358 },
5359 {
5360 "__typename": "CheckRun",
5361 "conclusion": "SUCCESS",
5362 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5363 "name": "rustfmt",
5364 "status": "COMPLETED",
5365 "workflowName": "CI"
5366 },
5367 {
5368 "__typename": "CheckRun",
5369 "conclusion": "FAILURE",
5370 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5371 "name": "editorconfig",
5372 "status": "COMPLETED",
5373 "workflowName": "CI"
5374 },
5375 {
5376 "__typename": "StatusContext",
5377 "context": "CodeRabbit",
5378 "state": "SUCCESS",
5379 "targetUrl": ""
5380 }
5381 ],
5382 "reviews": [],
5383 "comments": [
5384 {
5385 "author": {
5386 "login": "coderabbitai"
5387 },
5388 "authorAssociation": "NONE",
5389 "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"
5390 }
5391 ]
5392}"####;
5393
5394 const PENDING_OPEN: &str = r####"{
5396 "url": "https://github.com/yukimemi/magi/pull/9",
5397 "number": 9,
5398 "state": "OPEN",
5399 "statusCheckRollup": [
5400 {
5401 "__typename": "CheckRun",
5402 "conclusion": "SUCCESS",
5403 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5404 "name": "check (ubuntu-latest)",
5405 "status": "COMPLETED",
5406 "workflowName": "CI"
5407 },
5408 {
5409 "__typename": "CheckRun",
5410 "conclusion": "SUCCESS",
5411 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5412 "name": "rustfmt",
5413 "status": "COMPLETED",
5414 "workflowName": "CI"
5415 },
5416 {
5417 "__typename": "CheckRun",
5418 "conclusion": null,
5419 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5420 "name": "editorconfig",
5421 "status": "IN_PROGRESS",
5422 "workflowName": "CI"
5423 },
5424 {
5425 "__typename": "StatusContext",
5426 "context": "CodeRabbit",
5427 "state": "SUCCESS",
5428 "targetUrl": ""
5429 }
5430 ],
5431 "reviews": [],
5432 "comments": []
5433}"####;
5434
5435 const MERGED: &str = r####"{
5437 "url": "https://github.com/yukimemi/magi/pull/16",
5438 "number": 16,
5439 "state": "MERGED",
5440 "statusCheckRollup": [
5441 {
5442 "__typename": "CheckRun",
5443 "conclusion": "SUCCESS",
5444 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5445 "name": "check (ubuntu-latest)",
5446 "status": "COMPLETED",
5447 "workflowName": "CI"
5448 },
5449 {
5450 "__typename": "CheckRun",
5451 "conclusion": "SUCCESS",
5452 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5453 "name": "review",
5454 "status": "COMPLETED",
5455 "workflowName": "claude-review"
5456 }
5457 ],
5458 "reviews": [],
5459 "comments": []
5460}"####;
5461
5462 const REVIEWED_OPEN: &str = r####"{
5464 "url": "https://github.com/yukimemi/magi/pull/12",
5465 "number": 12,
5466 "state": "OPEN",
5467 "statusCheckRollup": [
5468 {
5469 "__typename": "CheckRun",
5470 "conclusion": "SUCCESS",
5471 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5472 "name": "check (ubuntu-latest)",
5473 "status": "COMPLETED",
5474 "workflowName": "CI"
5475 },
5476 {
5477 "__typename": "CheckRun",
5478 "conclusion": "SUCCESS",
5479 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5480 "name": "review",
5481 "status": "COMPLETED",
5482 "workflowName": "claude-review"
5483 }
5484 ],
5485 "reviews": [
5486 {
5487 "author": {
5488 "login": "claude"
5489 },
5490 "state": "COMMENTED",
5491 "body": ""
5492 }
5493 ],
5494 "comments": [
5495 {
5496 "author": {
5497 "login": "coderabbitai"
5498 },
5499 "authorAssociation": "NONE",
5500 "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"
5501 },
5502 {
5503 "author": {
5504 "login": "claude"
5505 },
5506 "authorAssociation": "NONE",
5507 "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"
5508 }
5509 ]
5510}"####;
5511
5512 const INLINE: &str = r####"[
5514 {
5515 "user": {
5516 "login": "claude[bot]"
5517 },
5518 "path": "src/graph.rs",
5519 "line": 231,
5520 "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"
5521 }
5522]"####;
5523
5524 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5526<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5527
5528> [!IMPORTANT]
5529> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5530>
5531> This repository does not receive automatic reviews because it has fewer than 10 stars.
5532>
5533> <details>
5534> <summary>⚙️ Run configuration</summary>
5535>
5536> **Configuration used**: defaults
5537>
5538> **Review profile**: CHILL
5539>
5540> **Plan**: Team
5541>
5542> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5543>
5544> </details>
5545
5546<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5547
5548<!-- tips_start -->
5549
5550---
5551
5552Thanks 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.
5553
5554<details>
5555<summary>❤️ Share</summary>
5556
5557- [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"####;
5558
5559 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5561
5562---
5563### Reviewing PR #16
5564
5565- [x] Read AGENTS.md conventions
5566- [x] Review `src/daemon.rs` changes
5567- [x] Review `src/main.rs` changes (new `doctor` reporting)
5568- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5569- [x] Check test coverage for new behavior
5570- [x] Run verification commands (blocked — see note)
5571- [x] Post findings"####;
5572
5573 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5575
5576---
5577### Review: `magi review <branch>` — cheap-half-only graph
5578
5579Read 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.
5580
5581**Correctness**
5582
5583- 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"####;
5584
5585 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5586 PrState {
5587 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5588 number: 16,
5589 state: PrLifecycle::Open,
5590 checks,
5591 blocking: if matches!(checks, Checks::Red) {
5595 Blocking::Yes
5596 } else {
5597 Blocking::No
5598 },
5599 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5600 review_comments: (0..comments)
5601 .map(|i| ReviewComment {
5602 author: "coderabbitai".to_owned(),
5603 path: Some("src/graph.rs".to_owned()),
5604 line: Some(231),
5605 body: format!("finding {i}"),
5606 })
5607 .collect(),
5608 }
5609 }
5610
5611 #[test]
5612 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5613 use CiExpectation::{Absent, Expected};
5614 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5615 let p = pr(checks, &["x"], 0);
5616 for waited in [Duration::ZERO, CHECKS_GRACE] {
5617 assert_eq!(
5618 decide_with(&p, 0, 4, waited, Expected),
5619 decide(&p, 0, 4, waited)
5620 );
5621 }
5622 }
5623 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5625 let p = pr(checks, &["x"], 0);
5626 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5627 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5628 }
5629 let mut p = pr(Checks::Unknown, &[], 0);
5631 p.blocking = Blocking::Conflict;
5632 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5633 p.state = PrLifecycle::Merged;
5635 assert_eq!(
5636 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5637 Step::Done { merged: true }
5638 );
5639 }
5640
5641 #[test]
5642 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5643 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5644 assert_eq!(state.number, 10);
5645 assert_eq!(state.state, PrLifecycle::Open);
5646 assert_eq!(state.checks, Checks::Green);
5647 assert!(state.failing.is_empty());
5648 assert!(
5649 state.review_comments.is_empty(),
5650 "the only comment is CodeRabbit's trigger notice: {:?}",
5651 state.review_comments
5652 );
5653 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5654 }
5655
5656 #[test]
5657 fn a_failing_check_parses_as_red_and_is_named() {
5658 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5659 assert_eq!(state.checks, Checks::Red);
5660 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5661 let mut blocking = state.clone();
5668 blocking.blocking = Blocking::Yes;
5669 match decide(&blocking, 0, 4, Duration::ZERO) {
5670 Step::Fix { reason } => {
5671 assert!(reason.contains("editorconfig"), "reason: {reason}");
5672 assert!(reason.contains("failing"), "reason: {reason}");
5673 }
5674 other => panic!("expected a fix round, got {other:?}"),
5675 }
5676 }
5677
5678 #[test]
5679 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5680 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5681 assert_eq!(state.checks, Checks::Pending);
5682 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5683 }
5684
5685 #[test]
5686 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5687 let state = parse_pr(MERGED).expect("merged fixture parses");
5688 assert_eq!(state.state, PrLifecycle::Merged);
5689 assert_eq!(
5690 decide(&state, 0, 4, Duration::ZERO),
5691 Step::Done { merged: true }
5692 );
5693 }
5694
5695 #[test]
5696 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5697 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5698 assert_eq!(state.checks, Checks::Green);
5699 let authors: Vec<&str> = state
5700 .review_comments
5701 .iter()
5702 .map(|c| c.author.as_str())
5703 .collect();
5704 assert_eq!(
5705 authors,
5706 vec!["claude"],
5707 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5708 );
5709 match decide(&state, 0, 4, Duration::ZERO) {
5710 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5711 other => panic!("expected a fix round, got {other:?}"),
5712 }
5713 }
5714
5715 #[test]
5716 fn inline_review_comments_keep_their_file_and_line() {
5717 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5718 assert_eq!(comments.len(), 1);
5719 assert_eq!(comments[0].author, "claude[bot]");
5720 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5721 assert_eq!(comments[0].line, Some(231));
5722 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5723 }
5724
5725 #[test]
5726 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5727 assert!(
5728 is_noise(CODERABBIT_TRIGGER),
5729 "CodeRabbit's trigger notice declares itself not a review"
5730 );
5731 assert!(
5732 is_noise(CLAUDE_CHECKLIST),
5733 "a progress checklist asks for nothing"
5734 );
5735 assert!(
5736 !is_noise(CLAUDE_FINDING),
5737 "a review that names a bug is input, not noise"
5738 );
5739
5740 let mut clean = pr(Checks::Green, &[], 0);
5741 clean.review_comments.push(ReviewComment {
5742 author: "coderabbitai".to_owned(),
5743 path: None,
5744 line: None,
5745 body: CODERABBIT_TRIGGER.to_owned(),
5746 });
5747 clean.review_comments.retain(|c| !is_noise(&c.body));
5748 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5749
5750 let mut found = pr(Checks::Green, &[], 0);
5751 found.review_comments.push(ReviewComment {
5752 author: "claude".to_owned(),
5753 path: None,
5754 line: None,
5755 body: CLAUDE_FINDING.to_owned(),
5756 });
5757 found.review_comments.retain(|c| !is_noise(&c.body));
5758 assert!(matches!(
5759 decide(&found, 0, 4, Duration::ZERO),
5760 Step::Fix { .. }
5761 ));
5762 }
5763
5764 #[test]
5765 fn the_policy_table_holds_for_every_combination_that_matters() {
5766 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5767 (
5768 "pending checks are waited for, even on the last round",
5769 pr(Checks::Pending, &[], 0),
5770 4,
5771 4,
5772 Duration::ZERO,
5773 Step::Wait,
5774 ),
5775 (
5776 "red checks are fixed",
5777 pr(Checks::Red, &["editorconfig"], 0),
5778 0,
5779 4,
5780 Duration::ZERO,
5781 Step::Fix {
5782 reason: "1 check(s) failing: editorconfig".to_owned(),
5783 },
5784 ),
5785 (
5786 "green with comments is fixed, not merged",
5787 pr(Checks::Green, &[], 2),
5788 1,
5789 4,
5790 Duration::ZERO,
5791 Step::Fix {
5792 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5793 .to_owned(),
5794 },
5795 ),
5796 (
5797 "green and clean merges",
5798 pr(Checks::Green, &[], 0),
5799 3,
5800 4,
5801 Duration::ZERO,
5802 Step::Merge,
5803 ),
5804 (
5805 "an unreadable rollup is waited on while the grace lasts",
5806 pr(Checks::Unknown, &[], 0),
5807 0,
5808 4,
5809 Duration::ZERO,
5810 Step::Wait,
5811 ),
5812 (
5813 "an unreadable rollup is never merged once the grace is spent",
5814 pr(Checks::Unknown, &[], 0),
5815 0,
5816 4,
5817 CHECKS_GRACE,
5818 Step::GiveUp {
5819 reason: "no check status is readable on the pull request after 3 minute(s); \
5820 refusing to merge on a guess"
5821 .to_owned(),
5822 },
5823 ),
5824 ];
5825 for (what, state, round, budget, waited, want) in cases {
5826 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5827 }
5828 }
5829
5830 #[test]
5831 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5832 let green = parse_pr(GREEN_OPEN).expect("parse");
5836 assert_eq!(green.blocking, Blocking::No);
5837 let red = parse_pr(RED_OPEN).expect("parse");
5838 assert_eq!(
5839 red.blocking,
5840 Blocking::No,
5841 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5842 );
5843 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5844 let quiet =
5846 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5847 assert_eq!(quiet.blocking, Blocking::Unsaid);
5848 }
5849
5850 #[test]
5851 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5852 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5858 nonblocking.blocking = Blocking::No;
5859 assert_eq!(
5860 decide(&nonblocking, 0, 4, Duration::ZERO),
5861 Step::Merge,
5862 "the forge says nothing is in the way, so nothing is"
5863 );
5864
5865 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5867 blocking.blocking = Blocking::Yes;
5868 assert!(matches!(
5869 decide(&blocking, 0, 4, Duration::ZERO),
5870 Step::Fix { .. }
5871 ));
5872
5873 let mut commented = pr(Checks::Red, &["coverage"], 1);
5876 commented.blocking = Blocking::No;
5877 assert!(matches!(
5878 decide(&commented, 0, 4, Duration::ZERO),
5879 Step::Fix { .. }
5880 ));
5881
5882 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5884 unsaid.blocking = Blocking::Unsaid;
5885 assert!(matches!(
5886 decide(&unsaid, 0, 4, Duration::ZERO),
5887 Step::Fix { .. }
5888 ));
5889 }
5890
5891 #[test]
5892 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5893 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5894 red.blocking = Blocking::No;
5895 assert_eq!(
5896 decide(&red, 0, 4, Duration::ZERO),
5897 Step::Merge,
5898 "announcing must not change the decision"
5899 );
5900 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5901 assert!(said.contains("yukimemi/magi"), "{said}");
5902 assert!(said.contains("#16"), "{said}");
5903 assert!(
5904 said.contains("https://github.com/yukimemi/magi/pull/16"),
5905 "{said}"
5906 );
5907 assert!(
5908 said.contains("test (windows-latest)") && said.contains("coverage"),
5909 "{said}"
5910 );
5911
5912 let green = pr(Checks::Green, &["stale"], 0);
5914 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5915 }
5916
5917 #[test]
5918 fn red_merge_notices_omit_empty_check_details() {
5919 for checks in [vec![], vec!["", " "]] {
5920 let red = pr(Checks::Red, &checks, 0);
5921 let summary = red_merge_summary("owner/repo", &red).unwrap();
5922 let notice = crate::notices::merged_red("run-1", &summary);
5923 assert_eq!(
5924 notice.message,
5925 format!("Merged owner/repo PR #16 with red checks ({})", red.url)
5926 );
5927 assert_eq!(notice.severity, crate::notices::Severity::Warn);
5928 }
5929 let red = pr(Checks::Red, &["", " test ", " "], 0);
5930 let summary = red_merge_summary("owner/repo", &red).unwrap();
5931 assert!(summary.contains("with red checks: test ("), "{summary}");
5932 }
5933
5934 #[test]
5935 fn the_repo_label_comes_from_the_pull_request_url() {
5936 let p = Path::new("/tmp/checkout");
5937 assert_eq!(
5938 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5939 "yukimemi/magi"
5940 );
5941 assert_eq!(repo_label(p, "not a url"), "checkout");
5942 }
5943
5944 #[test]
5945 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5946 let mut conflicted = pr(Checks::Green, &[], 0);
5951 conflicted.blocking = Blocking::Conflict;
5952 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5953
5954 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5958 red.blocking = Blocking::Conflict;
5959 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5960
5961 let mut merged = pr(Checks::Red, &[], 0);
5963 merged.blocking = Blocking::Conflict;
5964 merged.state = PrLifecycle::Merged;
5965 assert_eq!(
5966 decide(&merged, 0, 4, Duration::ZERO),
5967 Step::Done { merged: true }
5968 );
5969 }
5970
5971 #[test]
5972 fn the_forge_verdict_is_read_off_merge_state_status() {
5973 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5976 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5977 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5978 }
5979 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5980 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5981 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5982 for quiet in ["", "UNKNOWN"] {
5985 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5986 assert!(Blocking::of(quiet).stops_a_merge());
5987 }
5988 }
5989
5990 #[test]
5991 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5992 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5993 let jj = "could not determine current branch: failed to run git: not on any branch";
5995
5996 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5997 .expect("the forge says merged, so it merged");
5998 assert!(landed.ok);
5999 assert!(
6000 landed.detail.contains("but the pull request is merged"),
6001 "the record must not read as a clean success: {}",
6002 landed.detail
6003 );
6004 assert!(
6005 landed.detail.contains("not on any branch"),
6006 "and it must keep what the command actually said: {}",
6007 landed.detail
6008 );
6009
6010 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6012 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6013 assert!(merged_after_all(&argv, jj, None).is_none());
6015 }
6016
6017 #[test]
6018 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6019 let mut state = pr(Checks::Red, &["editorconfig"], 3);
6020 state.state = PrLifecycle::Closed;
6021 assert_eq!(
6022 decide(&state, 0, 4, Duration::ZERO),
6023 Step::Done { merged: false },
6024 "a human closing the pull request ends the loop, whatever CI says"
6025 );
6026 }
6027
6028 #[test]
6029 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6030 let red = decide(
6031 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6032 4,
6033 4,
6034 Duration::ZERO,
6035 );
6036 match red {
6037 Step::GiveUp { reason } => {
6038 assert!(reason.contains("editorconfig"), "reason: {reason}");
6039 assert!(reason.contains("test (macos)"), "reason: {reason}");
6040 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6041 }
6042 other => panic!("expected a give-up, got {other:?}"),
6043 }
6044
6045 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6046 match commented {
6047 Step::GiveUp { reason } => {
6048 assert!(reason.contains("unresolved"), "reason: {reason}");
6049 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6050 }
6051 other => panic!("expected a give-up, got {other:?}"),
6052 }
6053 }
6054
6055 #[test]
6056 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6057 let candidate_commit = "magi: candidate A (uncommitted work)";
6058 let subject = merge_subject(candidate_commit, "add retries to the uploader");
6059 let argv = merge_argv(16, &subject);
6060
6061 assert!(argv.contains(&"--squash".to_owned()));
6062 assert!(argv.contains(&"--delete-branch".to_owned()));
6063 assert!(argv.contains(&"--subject".to_owned()));
6064 assert_eq!(
6065 argv.last().map(String::as_str),
6066 Some("add retries to the uploader"),
6067 "the subject must not be the candidate commit message"
6068 );
6069 assert_ne!(subject, candidate_commit);
6070 }
6071
6072 #[test]
6073 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6074 assert_eq!(
6075 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6076 "feat: a queue, an unattended loop, and a phone UI"
6077 );
6078 assert_eq!(
6079 merge_subject("", "# port the retry logic\n\ndetails"),
6080 "port the retry logic",
6081 "an empty title falls back to the task's first line, heading marks stripped"
6082 );
6083 }
6084
6085 #[test]
6086 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6087 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6088 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6089 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6090 assert_eq!(job_of("https://coderabbit.ai/status"), None);
6091 assert_eq!(run_of(""), None);
6092 }
6093
6094 #[test]
6095 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6096 let mut out = Vec::new();
6097 push_if_outstanding(
6098 &mut out,
6099 ReviewComment {
6100 author: "yukimemi".to_owned(),
6101 path: None,
6102 line: None,
6103 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6104 },
6105 );
6106 assert!(out.is_empty());
6107 }
6108
6109 fn run_state() -> RunState {
6113 let mut state = RunState::new(
6114 std::path::PathBuf::from("/repo/magi"),
6115 "main".to_owned(),
6116 "abcdef1234".to_owned(),
6117 "add retries to the uploader".to_owned(),
6118 crate::config::Config::default(),
6119 );
6120 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6125 let nanos = std::time::SystemTime::now()
6126 .duration_since(std::time::UNIX_EPOCH)
6127 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6128 state.id = format!(
6129 "20261004-{nanos:06}-{:04x}",
6130 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6131 );
6132 state
6133 }
6134
6135 fn green_pr() -> PrState {
6136 PrState {
6137 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6138 number: 42,
6139 state: PrLifecycle::Open,
6140 checks: Checks::Green,
6141 blocking: Blocking::No,
6143 failing: Vec::new(),
6144 review_comments: vec![ReviewComment {
6145 author: "coderabbitai".to_owned(),
6146 path: Some("src/land.rs".to_owned()),
6147 line: Some(212),
6148 body: "this branch never checks the exit code".to_owned(),
6149 }],
6150 }
6151 }
6152
6153 #[test]
6154 fn github_facing_land_text_is_english_whatever_the_language() {
6155 let mut state = run_state();
6156 state.config.graph.language = "ja".to_owned();
6157 let comment = stop_comment(&state.id, "checks are still red");
6158 assert!(crate::github_text::check("", &comment).is_empty());
6159 assert!(comment.is_ascii(), "{comment}");
6160 assert!(comment.starts_with(MARKER));
6161
6162 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6163 let ja_at = p.find("Write all prose in ja").unwrap();
6164 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6165 assert!(ja_at < rule_at, "{p}");
6166 assert!(p.contains("stays in Japanese"), "{p}");
6167
6168 state.config.graph.language = "en".to_owned();
6169 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6170 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6171 assert!(!p.contains("does not apply"), "{p}");
6172 }
6173
6174 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6175
6176 fn panel() -> String {
6177 approval_panel(
6178 &run_state(),
6179 &green_pr(),
6180 NUMSTAT,
6181 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6182 &[
6183 "land: ask before merging".to_owned(),
6184 "land: colour the diff".to_owned(),
6185 ],
6186 "feat: merge approval from the phone",
6187 )
6188 }
6189
6190 #[test]
6191 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6192 let html = panel();
6193 for needle in [
6194 "42",
6195 "main",
6196 "src/land.rs",
6197 "src/web.rs",
6198 "assets/logo.png",
6199 "feat: merge approval from the phone",
6200 "land: ask before merging",
6201 "land: colour the diff",
6202 "coderabbitai",
6203 "this branch never checks the exit code",
6204 "green",
6205 ] {
6206 assert!(html.contains(needle), "the panel must state `{needle}`");
6207 }
6208 }
6209
6210 fn winning_candidate(summary: &str) -> Candidate {
6213 Candidate {
6214 index: 0,
6215 label: 'A',
6216 agent: "opus".to_owned(),
6217 branch: "magi/x/A".to_owned(),
6218 worktree: PathBuf::from("/wt/A"),
6219 summary: summary.to_owned(),
6220 stat: String::new(),
6221 files: 1,
6222 commits: 1,
6223 empty: false,
6224 failed: None,
6225 verified_noop: None,
6226 duration_ms: 0,
6227 folded: false,
6228 }
6229 }
6230
6231 fn uncontested_tally() -> Tally {
6232 Tally {
6233 first_choice: BTreeMap::from([('A', 1)]),
6234 borda: BTreeMap::new(),
6235 winner: 'A',
6236 rankings: 1,
6237 unanimous_initial: true,
6238 deliberated: false,
6239 changed_votes: 0,
6240 unanimous_final: true,
6241 tie_break: None,
6242 judges: 1,
6243 present: 1,
6244 quorum: 1,
6245 met_quorum: true,
6246 uncontested: None,
6247 }
6248 }
6249
6250 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
6251 ReviewRecord {
6252 attempts: 0,
6253 reviewer,
6254 agent: agent.to_owned(),
6255 summary: summary.to_owned(),
6256 findings: Vec::new(),
6257 vote: None,
6258 failed: None,
6259 duration_ms: 0,
6260 }
6261 }
6262
6263 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
6264 let answered = reviews.len();
6265 ReviewRound {
6266 round,
6267 head: "abc1234".to_owned(),
6268 verified_head: None,
6269 verified_at: None,
6270 reviews,
6271 e2e: Vec::new(),
6272 verify_retried: false,
6273 e2e_deferred: false,
6274 e2e_defer_reason: None,
6275 fix: None,
6276 blocking: 0,
6277 answered,
6278 expected: answered,
6279 clean: true,
6280 progressed: false,
6281 vote_split: false,
6282 reconsideration: Vec::new(),
6283 verdict: None,
6284 }
6285 }
6286
6287 #[test]
6288 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
6289 let en = panel();
6290 assert!(en.contains("Task"), "{en}");
6291 assert!(en.contains("add retries to the uploader"), "{en}");
6292
6293 let mut state = run_state();
6294 state.config.graph.language = "ja".to_owned();
6295 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6296 assert!(ja.contains("タスク"), "{ja}");
6297 assert!(
6298 ja.contains("add retries to the uploader"),
6299 "the task itself is not translated: {ja}"
6300 );
6301 }
6302
6303 #[test]
6304 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
6305 let html = panel();
6309 assert!(!html.contains("What changed"), "{html}");
6310 assert!(!html.contains("Review verdict"), "{html}");
6311 }
6312
6313 #[test]
6314 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
6315 let mut state = run_state();
6316 state.candidates = vec![winning_candidate("")];
6317 state.tally = Some(uncontested_tally());
6318 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6319 assert!(
6320 !html.contains("What changed"),
6321 "an empty summary must not render an empty box: {html}"
6322 );
6323 }
6324
6325 #[test]
6326 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
6327 let mut state = run_state();
6328 state.candidates = vec![winning_candidate(
6329 "Added a retry loop around the uploader PUT call.",
6330 )];
6331 state.tally = Some(uncontested_tally());
6332 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6333 assert!(en.contains("What changed"), "{en}");
6334 assert!(
6335 en.contains("Added a retry loop around the uploader PUT call."),
6336 "{en}"
6337 );
6338
6339 state.config.graph.language = "ja".to_owned();
6340 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6341 assert!(ja.contains("変更内容"), "{ja}");
6342 assert!(
6343 ja.contains("Added a retry loop around the uploader PUT call."),
6344 "{ja}"
6345 );
6346 }
6347
6348 #[test]
6349 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
6350 let mut state = run_state();
6351 state.reviews = vec![
6352 review_round(
6353 1,
6354 vec![review_record(1, "alpha", "found a race, sent back")],
6355 ),
6356 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
6357 ];
6358 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6359 assert!(en.contains("Review verdict"), "{en}");
6360 assert!(en.contains("race is fixed, clean"), "{en}");
6361 assert!(
6362 !en.contains("found a race, sent back"),
6363 "only the round that actually cleared the merge should show: {en}"
6364 );
6365
6366 state.config.graph.language = "ja".to_owned();
6367 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6368 assert!(ja.contains("レビューの結論"), "{ja}");
6369 assert!(ja.contains("レビュアー"), "{ja}");
6370 assert!(ja.contains("race is fixed, clean"), "{ja}");
6371 }
6372
6373 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
6379 ReviewRecord {
6380 attempts: 0,
6381 reviewer,
6382 agent: agent.to_owned(),
6383 summary: String::new(),
6384 findings: Vec::new(),
6385 vote: None,
6386 failed: Some(reason.to_owned()),
6387 duration_ms: 0,
6388 }
6389 }
6390
6391 #[test]
6392 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
6393 let mut state = run_state();
6394 state.reviews = vec![review_round(
6395 1,
6396 vec![
6397 review_record(1, "alpha", "clean, nothing to add"),
6398 unanswered_review_record(2, "beta", "timed out"),
6399 ],
6400 )];
6401 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6402 assert!(en.contains("clean, nothing to add"), "{en}");
6403 assert!(
6404 en.contains("produced no answer: timed out"),
6405 "a seat that never answered must say so, not render a blank box: {en}"
6406 );
6407 assert!(
6408 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6409 "no reviewer box may be left empty: {en}"
6410 );
6411
6412 state.config.graph.language = "ja".to_owned();
6413 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6414 assert!(ja.contains("回答なし: timed out"), "{ja}");
6415 }
6416
6417 #[test]
6418 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6419 let html = panel();
6420 assert!(!html.contains("<script"), "no script survives the csp");
6421 assert!(!html.contains("<form"), "form-action is 'none'");
6422 let pr = green_pr();
6423 assert_eq!(
6424 html.matches("http").count(),
6425 html.matches(pr.url.as_str()).count(),
6426 "the only http url in the panel is the pull request's own link"
6427 );
6428 assert!(
6429 html.contains("target=\"_blank\""),
6430 "the PR link must open in a new tab so it escapes the iframe"
6431 );
6432 assert!(
6433 html.contains("rel=\"noopener noreferrer\""),
6434 "external links opening in new tabs must carry noopener noreferrer"
6435 );
6436 }
6437
6438 #[test]
6439 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6440 let html = panel();
6441 assert!(
6442 html.contains(">+</span>"),
6443 "an added line carries a `+` in the gutter, not only a background"
6444 );
6445 assert!(
6446 html.contains(">-</span>"),
6447 "a removed line carries a `-` in the gutter, not only a background"
6448 );
6449 assert!(
6450 html.contains(">new line</span>"),
6451 "the marker is moved to the gutter, so the body is printed once without it"
6452 );
6453 }
6454
6455 #[test]
6456 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6457 let total = DIFF_MAX_LINES + 100;
6458 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6459 let html = approval_panel(
6460 &run_state(),
6461 &green_pr(),
6462 NUMSTAT,
6463 &diff,
6464 &[],
6465 "feat: something long",
6466 );
6467 assert!(
6468 html.contains(&format!("100 of {total} diff lines omitted")),
6469 "the note must say exactly how much was cut"
6470 );
6471 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6472 assert!(
6473 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6474 "nothing past the threshold is rendered"
6475 );
6476 assert!(
6477 html.contains("/repo/magi"),
6478 "the note says where the rest is"
6479 );
6480 }
6481
6482 #[test]
6483 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6484 let html = approval_panel(
6485 &run_state(),
6486 &green_pr(),
6487 "1\t2\tsrc/<b>&\"x\"'.rs",
6488 "",
6489 &[],
6490 "subject",
6491 );
6492 assert!(html.contains("src/<b>&"x"'.rs"));
6493 assert!(
6494 !html.contains("<b>"),
6495 "an agent-influenced path must never become markup"
6496 );
6497 }
6498
6499 #[tokio::test]
6500 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6501 let a = std::path::PathBuf::from("/repo/a");
6502 let b = std::path::PathBuf::from("/repo/b");
6503
6504 let held = repo_merge_lock(&a).lock_owned().await;
6505
6506 assert!(
6509 repo_merge_lock(&a).try_lock().is_err(),
6510 "a second merge into the same repository must not proceed concurrently"
6511 );
6512
6513 assert!(
6517 repo_merge_lock(&b).try_lock().is_ok(),
6518 "a different repository's merge lock must be independent"
6519 );
6520
6521 drop(held);
6522 assert!(
6523 repo_merge_lock(&a).try_lock().is_ok(),
6524 "the lock is released once the holder is done"
6525 );
6526 }
6527
6528 pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
6529 let mut state = landing_state();
6530 let mut rec = review_record(1, "alpha", "precise rejection reason");
6531 rec.vote = Some(if reject {
6532 crate::verdict::ReviewVote::Reject
6533 } else {
6534 crate::verdict::ReviewVote::Approve
6535 });
6536 if major {
6537 rec.findings = contested().findings;
6538 }
6539 let mut round = review_round(1, vec![rec]);
6540 round.clean = false;
6541 round.blocking = usize::from(major);
6542 state.reviews.push(round);
6543 state.config.graph.review_rounds = 1;
6544 state
6545 }
6546
6547 #[test]
6548 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6549 for (major, reject) in [(true, false), (false, true), (true, true)] {
6550 let state = repair_state(major, reject);
6551 assert!(repair_available(&state));
6552 assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
6553 }
6554 assert!(!repair_available(&repair_state(false, false)));
6555 let mut clean = repair_state(true, false);
6556 clean.reviews[0].clean = true;
6557 assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
6558 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6559 }
6560
6561 #[tokio::test]
6562 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6563 let mut state = repair_state(true, false);
6564 let pr = green_pr();
6565 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6566 .await
6567 .unwrap();
6568 let store = ask::Questions::open();
6569 let id = state.land_approval.as_ref().unwrap().question.clone();
6570 store
6572 .update(&id, |q| {
6573 q.choices = vec![APPROVE.into(), HOLD.into()];
6574 Ok(())
6575 })
6576 .unwrap();
6577 let upgraded = store.get(&id).unwrap();
6578 assert_eq!(upgraded.choices, approval_choices(&state));
6579 assert!(!upgraded.actions.contains_key(HOLD));
6580 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6581 assert!(
6582 store
6583 .list()
6584 .iter()
6585 .any(|q| q.id == id && q.choices == upgraded.choices)
6586 );
6587 assert!(
6588 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6589 );
6590 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6591 store
6592 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6593 .unwrap();
6594 store
6597 .update(&id, |q| {
6598 q.actions.clear();
6599 Ok(())
6600 })
6601 .unwrap();
6602 assert_eq!(
6603 store.get(&id).unwrap().chosen_action(),
6604 Some(&ask::ChoiceAction::Resume {
6605 run: state.id.clone()
6606 })
6607 );
6608 let binding = state.land_approval.clone();
6609 assert_eq!(
6610 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6611 .await
6612 .unwrap(),
6613 ApprovalGate::Repair
6614 );
6615 assert!(state.land_approval.is_none());
6616 assert_eq!(state.merge_repairs.len(), 1);
6617 let request = &state.merge_repairs[0];
6618 assert_eq!(request.question, id);
6619 assert_eq!(request.head, "abc");
6620 assert_eq!(request.pr, pr.url);
6621 assert_eq!(
6622 request.reviews[0].reviews[0].summary,
6623 "precise rejection reason"
6624 );
6625 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6626 assert_eq!(
6627 request.reviews[0].reviews[0].findings[0].detail,
6628 state.reviews[0].reviews[0].findings[0].detail
6629 );
6630 state.land_approval = binding;
6632 assert_eq!(
6633 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6634 .await
6635 .unwrap(),
6636 ApprovalGate::Repair
6637 );
6638 assert_eq!(state.merge_repairs.len(), 1);
6639 }
6640
6641 #[tokio::test]
6642 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6643 for choice in [REPAIR, HOLD] {
6644 let mut state = repair_state(false, true);
6645 let observed = seen("abc", Checks::Green, "CLEAN", false);
6646 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6647 .await
6648 .unwrap();
6649 let id = state.land_approval.as_ref().unwrap().question.clone();
6650 ask::Questions::open()
6651 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6652 .unwrap();
6653 let forge = Scripted::new(vec![observed], vec![(true, "")]);
6654 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6655 .await
6656 .unwrap();
6657 assert!(
6658 forge
6659 .argvs()
6660 .iter()
6661 .all(|a| a.iter().any(|s| s == "--disable-auto"))
6662 );
6663 assert!(!forge.calls().contains(&"fix"));
6664 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6665 assert_ne!(state.status, RunStatus::Merged);
6666 }
6667 }
6668
6669 #[test]
6670 fn only_the_merge_choice_merges_and_silence_holds() {
6671 let table = [
6672 (None, Approval::Hold),
6673 (Some("merge"), Approval::Merge),
6674 (Some(" merge\n"), Approval::Merge),
6675 (Some("hold"), Approval::Hold),
6676 (Some(""), Approval::Hold),
6677 (Some("yes"), Approval::Hold),
6678 ];
6679 for (answer, want) in table {
6680 assert_eq!(
6681 approval(answer),
6682 want,
6683 "answer {answer:?} must resolve to {want:?}"
6684 );
6685 }
6686 }
6687
6688 #[tokio::test]
6689 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6690 let mut state = landing_state();
6691 state.config.graph.land_approval = true;
6692 let pr = green_pr();
6693
6694 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6695 .await
6696 .unwrap();
6697 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6698 assert!(
6699 !state.parked,
6700 "approval_gate itself never sets `parked`; only its caller does"
6701 );
6702
6703 let store = ask::Questions::open();
6704 let filed: Vec<_> = store
6705 .list()
6706 .into_iter()
6707 .filter(|q| q.run == state.id)
6708 .collect();
6709 assert_eq!(filed.len(), 1, "exactly one question is filed");
6710 assert_eq!(filed[0].node, APPROVAL_NODE);
6711 assert_eq!(filed[0].choices, approval_choices(&state));
6712 assert!(filed[0].status.open());
6713
6714 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6718 .await
6719 .unwrap();
6720 assert_eq!(again, ApprovalGate::Pending);
6721 let still_one = store
6722 .list()
6723 .into_iter()
6724 .filter(|q| q.run == state.id)
6725 .count();
6726 assert_eq!(
6727 still_one, 1,
6728 "asking twice must not double-file the question"
6729 );
6730 }
6731
6732 #[tokio::test]
6733 async fn approving_the_existing_question_is_read_back_as_approved() {
6734 crate::run::pin_test_home();
6735 let mut state = run_state();
6736 state.config.graph.land_approval = true;
6737 let pr = green_pr();
6738 assert_eq!(
6739 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6740 .await
6741 .unwrap(),
6742 ApprovalGate::Pending
6743 );
6744
6745 let store = ask::Questions::open();
6746 let mut q = store
6747 .list()
6748 .into_iter()
6749 .find(|q| q.run == state.id)
6750 .expect("filed above");
6751 let talks = crate::talk::Talks::open();
6752 let cfg = crate::config::Config {
6753 agents: vec![crate::config::AgentSpec {
6754 id: "mock".into(),
6755 kind: crate::config::AgentKind::Command,
6756 model: None,
6757 command: vec!["true".into()],
6758 extra_args: Vec::new(),
6759 env: Default::default(),
6760 prompt_delivery: None,
6761 }],
6762 ..crate::config::Config::default()
6763 };
6764 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6765 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6766 crate::talk::drain(&mut talk, &talks).unwrap();
6767 assert_eq!(
6768 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6769 .await
6770 .unwrap(),
6771 ApprovalGate::Pending,
6772 "consultation cannot approve or hold the merge"
6773 );
6774 q = store.get(&q.id).unwrap();
6775 assert!(q.answer.is_none());
6776 talk.turns.push(crate::talk::Turn {
6777 sender: None,
6778 breaks: Some(Vec::new()),
6779 messages: Vec::new(),
6780 posted_by: None,
6781 who: crate::talk::Who::Operator,
6782 body: "merge abc".into(),
6783 at: Timestamp::now(),
6784 attachments: Vec::new(),
6785 usage: None,
6786 });
6787 talks.put(&mut talk).unwrap();
6788 crate::consult::validate_answer(
6789 &q,
6790 &talks,
6791 &talk.id,
6792 crate::queue::CHAT_NODE,
6793 APPROVE,
6794 Some("merge abc"),
6795 )
6796 .unwrap();
6797 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6798 store.put(&mut q).unwrap();
6799
6800 assert_eq!(
6801 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6802 .await
6803 .unwrap(),
6804 ApprovalGate::Approved
6805 );
6806 }
6807
6808 #[tokio::test]
6809 async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
6810 crate::run::pin_test_home();
6811 let store = ask::Questions::open();
6812 for via_list in [false, true] {
6813 let mut state = repair_state(false, true);
6814 let pr = green_pr();
6815 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6816 .await
6817 .unwrap();
6818 let id = state.land_approval.as_ref().unwrap().question.clone();
6819 store
6820 .update(&id, |q| {
6821 q.actions.insert(
6822 HOLD.to_owned(),
6823 ask::ChoiceAction::Resume {
6824 run: state.id.clone(),
6825 },
6826 );
6827 Ok(())
6828 })
6829 .unwrap();
6830 let q = if via_list {
6831 store.list().into_iter().find(|q| q.id == id).unwrap()
6832 } else {
6833 store.get(&id).unwrap()
6834 };
6835 assert!(!q.actions.contains_key(HOLD));
6836 store
6837 .update(&id, |q| {
6838 assert!(!q.actions.contains_key(HOLD));
6840 assert!(!upgrade_repair_choice(q, &state));
6841 q.answer(ask::Answer::Choice(HOLD.to_owned()))
6842 })
6843 .unwrap();
6844 assert!(store.get(&id).unwrap().chosen_action().is_none());
6845 assert_eq!(
6846 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6847 .await
6848 .unwrap(),
6849 ApprovalGate::Held
6850 );
6851 }
6852 }
6853
6854 #[tokio::test]
6855 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6856 crate::run::pin_test_home();
6857 let store = ask::Questions::open();
6858
6859 let mut held_state = run_state();
6860 held_state.config.graph.land_approval = true;
6861 let pr = green_pr();
6862 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6863 .await
6864 .unwrap();
6865 let mut q = store
6866 .list()
6867 .into_iter()
6868 .find(|q| q.run == held_state.id)
6869 .expect("filed above");
6870 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6871 store.put(&mut q).unwrap();
6872 assert_eq!(
6873 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6874 .await
6875 .unwrap(),
6876 ApprovalGate::Held
6877 );
6878
6879 let mut abandoned_state = run_state();
6880 abandoned_state.config.graph.land_approval = true;
6881 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6882 .await
6883 .unwrap();
6884 let mut q = store
6885 .list()
6886 .into_iter()
6887 .find(|q| q.run == abandoned_state.id)
6888 .expect("filed above");
6889 q.abandon("no answer within the timeout");
6890 store.put(&mut q).unwrap();
6891 assert_eq!(
6892 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6893 .await
6894 .unwrap(),
6895 ApprovalGate::Held,
6896 "silence must never merge"
6897 );
6898 }
6899
6900 fn contested() -> ContestedHandoff {
6901 let finding = |id: &str, n: u32| crate::verdict::Finding {
6902 id: id.to_owned(),
6903 severity: crate::verdict::Severity::Major,
6904 file: Some("src/a.rs".to_owned()),
6905 line: Some(n),
6906 title: format!("problem {id}"),
6907 detail: String::new(),
6908 };
6909 ContestedHandoff {
6910 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6911 rejecters: vec![(1, "alpha".to_owned())],
6912 }
6913 }
6914
6915 #[test]
6916 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6917 let mut state = run_state();
6918 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6919 state.contested_handoff = Some(contested());
6920 assert!(contested_to_ask(&state).is_some());
6921 state.config.graph.hold_contested_merge = false;
6922 assert!(
6923 contested_to_ask(&state).is_none(),
6924 "the switch restores today"
6925 );
6926 }
6927
6928 #[test]
6929 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6930 let q = ask::Question::new(
6931 "run-1".to_owned(),
6932 APPROVAL_NODE.to_owned(),
6933 "land".to_owned(),
6934 "Merge?".to_owned(),
6935 String::new(),
6936 vec![APPROVE.to_owned(), HOLD.to_owned()],
6937 );
6938 let none = deputy_brief(&q, None);
6939 assert!(none.contains("could not be read"), "{none}");
6940 assert!(none.contains("Silence is a hold"), "{none}");
6941
6942 let mut state = run_state();
6943 state.pr = Some(crate::run::PrRecord {
6944 url: "https://example.test/pull/7".to_owned(),
6945 number: 7,
6946 state: "open".to_owned(),
6947 checks: "green".to_owned(),
6948 round: 0,
6949 rounds: 3,
6950 red_at_merge: Vec::new(),
6951 });
6952 state.contested_handoff = Some(contested());
6953 let b = deputy_brief(&q, Some(&state));
6954 assert!(b.contains("https://example.test/pull/7"), "{b}");
6955 assert!(b.contains("[R3-1-1] major (src/a.rs:1)"), "{b}");
6956 assert!(b.contains("#1"), "the rejecting seat: {b}");
6957 state.contested_handoff = None;
6958 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6959 }
6960
6961 #[test]
6962 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6963 for lang in ["en", "ja"] {
6964 let mut cfg = crate::config::Config::default();
6965 cfg.graph.language = lang.to_owned();
6966 let w = words(&cfg.graph.language);
6967 let text = w.approval_detail(
6968 "https://github.com/yukimemi/magi/pull/42",
6969 "main",
6970 "feat: x",
6971 Some(&contested()),
6972 );
6973 assert!(text.contains("pull/42"), "{text}");
6974 assert!(
6975 text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
6976 "{text}"
6977 );
6978 assert!(text.contains("R3-1-5"), "{text}");
6979 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6980 assert!(text.contains("2"), "the rest are counted: {text}");
6981 assert!(text.contains("#1 (alpha)"), "{text}");
6982 }
6983 let plain = words("en").approval_detail("u", "main", "s", None);
6984 assert!(!plain.contains("reject"), "{plain}");
6985 }
6986
6987 #[tokio::test]
6988 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6989 crate::run::pin_test_home();
6990 let mut state = run_state();
6991 state.config.graph.land_approval = false;
6992 state.contested_handoff = Some(contested());
6993 let pr = green_pr();
6994 let c = contested_to_ask(&state);
6995 assert_eq!(
6996 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6997 .await
6998 .unwrap(),
6999 ApprovalGate::Pending,
7000 "silence is a hold"
7001 );
7002 let store = ask::Questions::open();
7003 let filed: Vec<_> = store
7004 .list()
7005 .into_iter()
7006 .filter(|q| q.run == state.id)
7007 .collect();
7008 assert_eq!(filed.len(), 1);
7009 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
7010
7011 assert_eq!(
7012 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7013 .await
7014 .unwrap(),
7015 ApprovalGate::Pending
7016 );
7017 let mut q = store
7018 .list()
7019 .into_iter()
7020 .find(|q| q.run == state.id)
7021 .unwrap();
7022 assert_eq!(q.id, filed[0].id, "the same question after a resume");
7023 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7024 store.put(&mut q).unwrap();
7025 assert_eq!(
7026 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
7027 .await
7028 .unwrap(),
7029 ApprovalGate::Approved
7030 );
7031 }
7032
7033 #[test]
7034 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
7035 let rows = parse_numstat(NUMSTAT);
7036 assert_eq!(
7037 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
7038 ["src/web.rs", "src/land.rs", "assets/logo.png"]
7039 );
7040 assert_eq!(rows[2].added, None, "a binary file has no line counts");
7041 }
7042 #[test]
7043 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
7044 let mut state = run_state();
7048 state.config.graph.language = "ja".to_owned();
7049 let pr = green_pr();
7050 let commits = ["c1".to_owned()];
7051
7052 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7053 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
7054 assert!(ja.contains("squash されるコミット"), "{ja}");
7055 assert!(ja.contains("レビューコメント"), "{ja}");
7056 assert!(ja.contains("差分"), "{ja}");
7057 assert!(
7058 !ja.contains("Commits being squashed"),
7059 "no English left over"
7060 );
7061
7062 let w = words("ja");
7063 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
7064 assert!(
7065 w.approval_detail("http://x/1", "main", "feat: x", None)
7066 .contains("パネル")
7067 );
7068
7069 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
7071 assert!(ja.contains("feat: x"), "nor is the merge subject");
7072
7073 state.config.graph.language = "en".to_owned();
7076 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
7077 assert!(en.contains("Commits being squashed"), "{en}");
7078 assert_eq!(words("Klingon").html_lang, "en");
7079 }
7080
7081 #[test]
7085 fn pick_open_pr_classifies_by_count_and_base() {
7086 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
7087 assert_eq!(
7088 pick_open_pr(one, "main").unwrap(),
7089 OpenPr::One {
7090 url: "https://x/pull/58".into(),
7091 title: "t".into()
7092 }
7093 );
7094 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
7095 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
7096 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
7097 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
7098 assert_eq!(
7099 pick_open_pr(two, "main").unwrap(),
7100 OpenPr::Many(vec!["u1".into(), "u2".into()])
7101 );
7102 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
7104 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
7105 assert!(pick_open_pr("not json", "main").is_err());
7106 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
7108 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
7109 }
7110
7111 #[test]
7112 fn pick_merged_pr_picks_the_unique_match() {
7113 let json = r#"[
7114 {"url": "https://github.com/o/r/pull/42", "number": 42,
7115 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
7116 ]"#;
7117 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7118 let found = pick_merged_pr(json, "main", created_at)
7119 .expect("valid json")
7120 .expect("one unambiguous match");
7121 assert_eq!(found.url, "https://github.com/o/r/pull/42");
7122 assert_eq!(found.number, 42);
7123 }
7124
7125 #[test]
7129 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
7130 let json = r#"[
7131 {"url": "https://github.com/o/r/pull/42", "number": 42,
7132 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
7133 {"url": "https://github.com/o/r/pull/43", "number": 43,
7134 "mergedAt": "2026-09-21T10: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]
7144 fn pick_merged_pr_ignores_a_different_base_branch() {
7145 let json = r#"[
7146 {"url": "https://github.com/o/r/pull/42", "number": 42,
7147 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
7148 ]"#;
7149 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7150 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7151 }
7152
7153 #[test]
7156 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
7157 let json = r#"[
7158 {"url": "https://github.com/o/r/pull/42", "number": 42,
7159 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
7160 ]"#;
7161 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
7162 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
7163 }
7164
7165 #[test]
7166 fn slug_of_pr_url_reads_host_owner_and_repo() {
7167 assert_eq!(
7168 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
7169 Some("github.com/yukimemi/shun")
7170 );
7171 }
7172
7173 #[test]
7174 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
7175 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
7176 assert_eq!(slug_of_pr_url("not a url at all"), None);
7177 assert_eq!(slug_of_pr_url("https://github.com"), None);
7178 }
7179
7180 #[test]
7181 fn slug_of_repo_url_reads_host_owner_and_repo() {
7182 assert_eq!(
7183 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
7184 Some("github.com/yukimemi/magi")
7185 );
7186 assert_eq!(slug_of_repo_url("https://github.com"), None);
7187 }
7188
7189 #[test]
7190 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
7191 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
7192 .expect("same repo, different case");
7193 }
7194
7195 #[test]
7200 fn ensure_same_repo_refuses_a_different_repo() {
7201 let err =
7202 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
7203 let msg = format!("{err:#}");
7204 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7205 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
7206 }
7207
7208 #[test]
7213 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
7214 let err = ensure_same_repo(
7215 "github.com/yukimemi/magi",
7216 "github.example.com/yukimemi/magi",
7217 )
7218 .unwrap_err();
7219 let msg = format!("{err:#}");
7220 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
7221 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
7222 }
7223
7224 #[tokio::test]
7229 async fn find_external_merge_returns_none_without_a_winner() {
7230 let state = RunState::new(
7231 PathBuf::from("/no/such/repo"),
7232 "main".to_owned(),
7233 "0000000000000000000000000000000000000000".to_owned(),
7234 "irrelevant".to_owned(),
7235 crate::config::Config::default(),
7236 );
7237 assert_eq!(find_external_merge(&state).await.unwrap(), None);
7238 }
7239
7240 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
7241 let mut run = RunState::new(
7242 PathBuf::from(repo),
7243 "main".to_owned(),
7244 "abcdef1234".to_owned(),
7245 "x".to_owned(),
7246 crate::config::Config::default(),
7247 );
7248 run.id = id.to_owned();
7249 run.status = status;
7250 run.pr = Some(crate::run::PrRecord {
7251 number: url.rsplit('/').next().unwrap().parse().unwrap(),
7252 url: url.to_owned(),
7253 state: state.to_owned(),
7254 checks: "red".to_owned(),
7255 round: 0,
7256 rounds: 2,
7257 red_at_merge: Vec::new(),
7258 });
7259 run.save_under(home).unwrap();
7260 }
7261
7262 fn recorded(home: &Path, id: &str) -> String {
7263 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
7264 serde_json::from_str::<RunState>(&body)
7265 .unwrap()
7266 .pr
7267 .unwrap()
7268 .state
7269 }
7270
7271 const PR: &str = "https://github.com/o/r/pull/7";
7272
7273 #[test]
7274 fn write_through_updates_predecessors_and_siblings_only() {
7275 let tmp = tempfile::tempdir().unwrap();
7276 let h = tmp.path();
7277 pr_run(
7278 h,
7279 "20261004-100000-aaaa",
7280 "/repo/r",
7281 RunStatus::Superseded,
7282 PR,
7283 "open",
7284 );
7285 pr_run(
7286 h,
7287 "20261004-100100-bbbb",
7288 "/repo/r",
7289 RunStatus::Blocked,
7290 PR,
7291 "open",
7292 );
7293 pr_run(
7295 h,
7296 "20261004-100200-cccc",
7297 "/repo/r",
7298 RunStatus::Landing,
7299 PR,
7300 "open",
7301 );
7302 pr_run(
7304 h,
7305 "20261004-100300-dddd",
7306 "/repo/other",
7307 RunStatus::Blocked,
7308 "https://github.com/o/other/pull/7",
7309 "open",
7310 );
7311 pr_run(
7313 h,
7314 "20261004-100400-eeee",
7315 "/repo/r",
7316 RunStatus::Blocked,
7317 "https://github.com/o/r/pull/8",
7318 "open",
7319 );
7320 pr_run(
7321 h,
7322 "20261004-100500-ffff",
7323 "/repo/r",
7324 RunStatus::Merged,
7325 PR,
7326 "open",
7327 );
7328 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
7329
7330 assert_eq!(
7331 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7332 2
7333 );
7334 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7335 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
7336 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7337 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7338 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7339 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
7341 assert_eq!(
7343 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
7344 0
7345 );
7346 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
7347 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
7348 }
7349
7350 #[test]
7351 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
7352 let tmp = tempfile::tempdir().unwrap();
7353 let h = tmp.path();
7354 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
7355 pr_run(
7356 h,
7357 "20261004-100000-aaaa",
7358 "/repo/r",
7359 RunStatus::Superseded,
7360 &url(1),
7361 "open",
7362 );
7363 pr_run(
7364 h,
7365 "20261004-100100-bbbb",
7366 "/repo/r",
7367 RunStatus::Blocked,
7368 &url(2),
7369 "open",
7370 );
7371 pr_run(
7372 h,
7373 "20261004-100200-cccc",
7374 "/repo/r",
7375 RunStatus::Ready,
7376 &url(3),
7377 "open",
7378 );
7379 pr_run(
7380 h,
7381 "20261004-100300-dddd",
7382 "/repo/r",
7383 RunStatus::Ready,
7384 &url(4),
7385 "open",
7386 );
7387 pr_run(
7388 h,
7389 "20261004-100400-eeee",
7390 "/repo/r",
7391 RunStatus::Implementing,
7392 &url(1),
7393 "open",
7394 );
7395 assert_eq!(stale_open_prs(h).len(), 4);
7396
7397 let mut known = BTreeMap::new();
7398 known.insert(url(1), PrLifecycle::Merged);
7399 known.insert(url(2), PrLifecycle::Closed);
7400 known.insert(url(3), PrLifecycle::Open);
7401 assert_eq!(apply_pr_states(h, &known), 2);
7403 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
7404 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
7405 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
7406 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
7407 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
7408 assert_eq!(apply_pr_states(h, &known), 0);
7409 }
7410
7411 use std::collections::VecDeque;
7414 use std::sync::Mutex;
7415
7416 struct Scripted {
7419 views: Mutex<VecDeque<Seen>>,
7420 merges: Mutex<VecDeque<(bool, String)>>,
7421 fix: Mutex<Option<Fixed>>,
7422 log: Mutex<Vec<&'static str>>,
7423 argvs: Mutex<Vec<Vec<String>>>,
7424 required: Mutex<Option<BTreeSet<String>>>,
7425 merged: Mutex<bool>,
7428 queued: Mutex<bool>,
7429 unreadable_after_merge: Mutex<bool>,
7431 reruns: Mutex<Vec<String>>,
7433 rerun_fails: Mutex<bool>,
7434 clock: Mutex<i64>,
7436 }
7437
7438 impl Scripted {
7439 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
7440 Self {
7441 views: Mutex::new(views.into()),
7442 merges: Mutex::new(
7443 merges
7444 .into_iter()
7445 .map(|(ok, m)| (ok, m.to_owned()))
7446 .collect(),
7447 ),
7448 fix: Mutex::new(None),
7449 log: Mutex::new(Vec::new()),
7450 argvs: Mutex::new(Vec::new()),
7451 required: Mutex::new(None),
7452 merged: Mutex::new(false),
7453 queued: Mutex::new(false),
7454 unreadable_after_merge: Mutex::new(false),
7455 reruns: Mutex::new(Vec::new()),
7456 rerun_fails: Mutex::new(false),
7457 clock: Mutex::new(1_000),
7458 }
7459 }
7460 fn argvs(&self) -> Vec<Vec<String>> {
7461 self.argvs.lock().unwrap().clone()
7462 }
7463 fn calls(&self) -> Vec<&'static str> {
7464 self.log.lock().unwrap().clone()
7465 }
7466 }
7467
7468 impl Forge for Scripted {
7469 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7470 self.log.lock().unwrap().push("view");
7471 if *self.unreadable_after_merge.lock().unwrap()
7472 && !self.argvs.lock().unwrap().is_empty()
7473 {
7474 anyhow::bail!("forge unreachable");
7475 }
7476 let mut v = self.views.lock().unwrap();
7477 let mut seen = if v.len() > 1 {
7478 v.pop_front().unwrap()
7479 } else {
7480 v[0].clone()
7481 };
7482 if *self.merged.lock().unwrap() {
7483 seen.pr.state = PrLifecycle::Merged;
7484 }
7485 Ok(seen)
7486 }
7487 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7488 self.log.lock().unwrap().push("merge");
7489 self.argvs.lock().unwrap().push(argv.to_vec());
7490 let out = self
7491 .merges
7492 .lock()
7493 .unwrap()
7494 .pop_front()
7495 .expect("unscripted merge");
7496 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7497 {
7498 *self.merged.lock().unwrap() = true;
7499 }
7500 Ok(out)
7501 }
7502 async fn poll(&self) {
7503 self.log.lock().unwrap().push("poll");
7504 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7505 }
7506 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7507 self.log.lock().unwrap().push("rerun");
7508 self.reruns.lock().unwrap().push(run.to_owned());
7509 if *self.rerun_fails.lock().unwrap() {
7510 anyhow::bail!("gh run rerun: no");
7511 }
7512 Ok(())
7513 }
7514 fn now(&self) -> i64 {
7515 *self.clock.lock().unwrap()
7516 }
7517 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7518 self.required.lock().unwrap().clone()
7519 }
7520 async fn fix(
7521 &self,
7522 _state: &mut RunState,
7523 _pr: &PrState,
7524 _round: usize,
7525 _budget: usize,
7526 _reason: &str,
7527 _logs: &str,
7528 ) -> Result<Fixed> {
7529 self.log.lock().unwrap().push("fix");
7530 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7531 }
7532 }
7533
7534 const REFUSED: &str =
7535 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7536
7537 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7538 let mut pr = green_pr();
7539 pr.checks = checks;
7540 pr.blocking = Blocking::of(merge_state);
7541 if !comments {
7542 pr.review_comments.clear();
7543 }
7544 Seen {
7545 pr,
7546 title: "feat: x".to_owned(),
7547 failing_urls: Vec::new(),
7548 head: head.to_owned(),
7549 rollup_head: head.to_owned(),
7550 merge_state: merge_state.to_owned(),
7551 contexts: Vec::new(),
7552 base: "main".to_owned(),
7553 }
7554 }
7555
7556 fn landing_state() -> RunState {
7557 crate::run::pin_test_home();
7558 let mut state = run_state();
7559 state.config.graph.land_approval = false;
7560 state
7561 }
7562
7563 #[test]
7564 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7565 assert!(!awaiting_new_head(None, "aaa"));
7566 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7567 assert!(awaiting_new_head(Some("abc123"), "def456"));
7568 assert!(awaiting_new_head(Some("abc123"), ""));
7569 }
7570
7571 #[test]
7572 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7573 let open = |c, m: &str| seen("a", c, m, false);
7574 let table = [
7575 (None, false, Refused::Pending),
7576 (
7577 Some(open(Checks::Pending, "BLOCKED")),
7578 false,
7579 Refused::Pending,
7580 ),
7581 (
7582 Some(open(Checks::Unknown, "BLOCKED")),
7583 false,
7584 Refused::Pending,
7585 ),
7586 (
7587 Some(open(Checks::Green, "UNKNOWN")),
7588 false,
7589 Refused::Pending,
7590 ),
7591 (Some(open(Checks::Green, "")), false, Refused::Pending),
7592 (
7593 Some(open(Checks::Green, "BLOCKED")),
7594 false,
7595 Refused::Recheck,
7596 ),
7597 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7598 ];
7599 for (after, rechecked, want) in table {
7600 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7601 }
7602 let mut closed = open(Checks::Green, "CLEAN");
7603 closed.pr.state = PrLifecycle::Closed;
7604 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7605 }
7606
7607 #[tokio::test]
7608 async fn a_normal_landing_merges_on_the_first_look() {
7609 let mut state = landing_state();
7610 let forge = Scripted::new(
7611 vec![seen("a", Checks::Green, "CLEAN", false)],
7612 vec![(true, "")],
7613 );
7614 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7615 .await
7616 .unwrap();
7617 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7619 assert_eq!(state.status, RunStatus::Merged);
7620 }
7621
7622 #[tokio::test]
7623 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7624 let mut state = landing_state();
7625 let forge = Scripted::new(
7626 vec![seen("a", Checks::Green, "CLEAN", false)],
7627 std::iter::repeat_n((true, ""), 100).collect(),
7628 );
7629 *forge.queued.lock().unwrap() = true;
7630 let task = async {
7631 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7632 .await
7633 .unwrap();
7634 };
7635 task.await;
7638 assert_ne!(state.status, RunStatus::Merged);
7639 }
7640
7641 #[tokio::test]
7642 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7643 let mut state = landing_state();
7644 let forge = Scripted::new(
7645 vec![seen("a", Checks::Green, "CLEAN", false)],
7646 std::iter::repeat_n((true, ""), 100).collect(),
7647 );
7648 *forge.unreadable_after_merge.lock().unwrap() = true;
7649 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7650 .await
7651 .ok();
7652 assert_ne!(state.status, RunStatus::Merged);
7653 }
7654
7655 #[tokio::test]
7656 async fn a_task_public_head_is_awaited_on_land_entry() {
7657 let mut state = landing_state();
7658 state.pr_head = Some("magi/task-abcd-1234".into());
7659 state.pr_pushed_head = Some("new".into());
7660 let forge = Scripted::new(
7661 vec![
7662 seen("old", Checks::Green, "CLEAN", false),
7663 seen("new", Checks::Green, "CLEAN", false),
7664 ],
7665 vec![(true, "")],
7666 );
7667 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7668 .await
7669 .unwrap();
7670 assert_eq!(
7671 forge.calls(),
7672 ["view", "poll", "view", "view", "merge", "view"]
7673 );
7674 assert_eq!(state.status, RunStatus::Merged);
7675 }
7676
7677 #[tokio::test]
7678 async fn a_superseded_public_head_owner_cannot_reenter_land() {
7679 let mut state = landing_state();
7680 state.pr_taken_over_by = Some("next".into());
7681 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7682 assert!(
7683 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7684 .await
7685 .is_err()
7686 );
7687 assert!(forge.calls().is_empty());
7688 }
7689
7690 #[tokio::test]
7691 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7692 let mut state = landing_state();
7693 let forge = Scripted::new(
7694 vec![
7695 seen("old", Checks::Green, "CLEAN", true),
7696 seen("old", Checks::Green, "CLEAN", true),
7698 seen("new", Checks::Pending, "BLOCKED", true),
7699 seen("new", Checks::Green, "CLEAN", true),
7700 ],
7701 vec![(true, "")],
7702 );
7703 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7704 head: "NEW".to_owned(),
7705 });
7706 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7707 .await
7708 .unwrap();
7709 assert_eq!(
7710 forge.calls(),
7711 [
7712 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7713 "view"
7714 ]
7715 );
7716 assert_eq!(state.status, RunStatus::Merged);
7717 }
7718
7719 #[tokio::test]
7720 async fn a_head_that_never_arrives_stops_naming_both_commits() {
7721 let mut state = landing_state();
7722 let forge = Scripted::new(
7723 vec![
7724 seen("old", Checks::Green, "CLEAN", true),
7725 seen("someone-elses", Checks::Green, "CLEAN", true),
7726 ],
7727 vec![],
7728 );
7729 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7730 head: "mine".to_owned(),
7731 });
7732 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7733 .await
7734 .unwrap();
7735 assert!(!forge.calls().contains(&"merge"));
7736 let why = state.merge.as_ref().unwrap().detail.clone();
7737 assert!(
7738 why.contains("mine") && why.contains("someone-elses"),
7739 "{why}"
7740 );
7741 assert_eq!(state.status, RunStatus::Blocked);
7742 }
7743
7744 #[tokio::test]
7745 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7746 let mut state = landing_state();
7747 let forge = Scripted::new(
7748 vec![
7749 seen("a", Checks::Green, "CLEAN", false),
7750 seen("a", Checks::Green, "CLEAN", false),
7751 seen("a", Checks::Pending, "BLOCKED", false),
7752 seen("a", Checks::Pending, "BLOCKED", false),
7753 seen("a", Checks::Green, "CLEAN", false),
7754 ],
7755 vec![(false, REFUSED), (true, "")],
7756 );
7757 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7758 .await
7759 .unwrap();
7760 assert_eq!(
7761 forge.calls(),
7762 [
7763 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7764 "view"
7765 ]
7766 );
7767 assert_eq!(state.status, RunStatus::Merged);
7768 }
7769
7770 #[tokio::test]
7771 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7772 let mut state = landing_state();
7773 let forge = Scripted::new(
7774 vec![
7775 seen("a", Checks::Green, "CLEAN", false),
7776 seen("a", Checks::Green, "BLOCKED", false),
7777 ],
7778 vec![(false, REFUSED), (false, REFUSED)],
7779 );
7780 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7781 .await
7782 .unwrap();
7783 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7785 let why = state.merge.as_ref().unwrap().detail.clone();
7786 assert!(
7787 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7788 "{why}"
7789 );
7790 assert_eq!(state.status, RunStatus::Blocked);
7791 }
7792
7793 #[test]
7794 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7795 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7796 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7797 assert_eq!(bound_head("old", "old", Some("new")), None);
7799 assert_eq!(bound_head("new", "old", Some("new")), None);
7801 assert_eq!(bound_head("new", "old", None), None);
7802 assert_eq!(bound_head("", "", None), None);
7804 assert_eq!(bound_head("", "", Some("new")), None);
7805 assert_eq!(bound_head("abc", "", None), None);
7806 }
7807
7808 fn view_json(head: &str) -> String {
7809 format!(
7810 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7811 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7812 "reviews":[],"comments":[]}}"#
7813 )
7814 }
7815
7816 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7817 format!(
7818 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7819 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7820 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7821 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7822 )
7823 }
7824
7825 #[test]
7826 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7827 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7830 assert_eq!(s.rollup_head, "old");
7831 assert_eq!(s.pr.checks, Checks::Red);
7832 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7833 assert_eq!(s.failing_urls.len(), 1);
7834 }
7835
7836 #[test]
7837 fn checks_come_from_the_node_not_the_view() {
7838 let view = view_json("new").replace(
7839 r#""reviews""#,
7840 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7841 );
7842 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7843 assert_eq!(s.pr.checks, Checks::Green);
7844 assert!(s.pr.failing.is_empty());
7845 assert_eq!(
7846 bound_head(&s.head, &s.rollup_head, Some("new")),
7847 Some("new")
7848 );
7849 }
7850
7851 #[test]
7852 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7853 for node in [
7854 None,
7855 Some("not json".to_owned()),
7856 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7857 Some(node_json("new", "SUCCESS", true)),
7858 ] {
7859 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7860 assert!(s.rollup_head.is_empty());
7861 assert_eq!(s.pr.checks, Checks::Unknown);
7862 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7863 }
7864 }
7865
7866 #[test]
7867 fn the_merge_command_is_pinned_to_the_observed_head() {
7868 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7869 let at = argv
7870 .iter()
7871 .position(|a| a == "--match-head-commit")
7872 .unwrap();
7873 assert_eq!(argv[at + 1], "deadbeef");
7874 }
7875
7876 #[tokio::test]
7877 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7878 let mut state = landing_state();
7879 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7882 stale.rollup_head = "old".to_owned();
7883 let forge = Scripted::new(
7884 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7885 vec![],
7886 );
7887 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7888 head: "new".to_owned(),
7889 });
7890 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7891 .await
7892 .unwrap();
7893 assert!(!forge.calls().contains(&"merge"));
7894 assert_eq!(state.status, RunStatus::Blocked);
7895 let why = state.merge.as_ref().unwrap().detail.clone();
7896 assert!(why.contains("new") && why.contains("old"), "{why}");
7897 }
7898
7899 #[test]
7900 fn a_refusal_read_against_another_commits_checks_is_pending() {
7901 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7902 after.rollup_head = "old".to_owned();
7903 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7904 }
7905
7906 #[tokio::test]
7907 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7908 let mut state = landing_state();
7909 let forge = Scripted::new(
7910 vec![seen("a", Checks::Red, "CLEAN", false)],
7911 vec![(true, "")],
7912 );
7913 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7914 .await
7915 .unwrap();
7916 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7917 assert_eq!(state.status, RunStatus::Merged);
7918 }
7919
7920 #[tokio::test]
7921 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7922 let mut state = landing_state();
7923 let forge = Scripted::new(
7924 vec![
7925 seen("a", Checks::Green, "CLEAN", false),
7926 seen("a", Checks::Green, "CLEAN", false),
7927 seen("b", Checks::Green, "BLOCKED", false),
7929 seen("b", Checks::Green, "CLEAN", false),
7930 ],
7931 vec![(false, REFUSED), (true, "")],
7932 );
7933 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7934 .await
7935 .unwrap();
7936 assert_eq!(
7937 forge.calls(),
7938 [
7939 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7940 ]
7941 );
7942 assert_eq!(state.status, RunStatus::Merged);
7943 }
7944
7945 fn merged_view(head: &str) -> Seen {
7946 let mut m = seen(head, Checks::Green, "CLEAN", false);
7947 m.pr.state = PrLifecycle::Merged;
7948 m
7949 }
7950
7951 fn has(argv: &[String], flag: &str) -> bool {
7952 argv.iter().any(|a| a == flag)
7953 }
7954
7955 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7956 let at = argv.iter().position(|a| a == flag)?;
7957 argv.get(at + 1).map(String::as_str)
7958 }
7959
7960 const URL: &str = "https://github.com/o/r/pull/42";
7961
7962 #[tokio::test]
7963 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7964 let mut state = landing_state();
7965 let forge = Scripted::new(
7966 vec![
7967 seen("abc", Checks::Green, "CLEAN", false),
7968 seen("abc", Checks::Green, "CLEAN", false),
7969 ],
7970 vec![(true, "")],
7971 );
7972 land_with(&mut state, URL, &forge).await.unwrap();
7973 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7974 let argv = &forge.argvs()[0];
7975 assert!(has(argv, "--squash") && has(argv, "--subject"));
7976 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7977 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7978 assert_eq!(state.status, RunStatus::Merged);
7979 assert!(state.land_armed_head.is_none());
7980 }
7981
7982 #[tokio::test]
7983 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7984 let mut state = landing_state();
7985 state.land_armed_head = Some("a".to_owned());
7986 let forge = Scripted::new(
7987 vec![seen("a", Checks::Green, "CLEAN", false)],
7988 vec![(true, ""), (true, "")],
7989 );
7990 land_with(&mut state, URL, &forge).await.unwrap();
7991 let argvs = forge.argvs();
7992 assert!(has(&argvs[0], "--disable-auto"));
7993 assert!(!has(&argvs[1], "--auto"));
7994 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7995 assert_eq!(state.status, RunStatus::Merged);
7996 assert!(state.land_armed_head.is_none());
7997 }
7998
7999 #[tokio::test]
8000 async fn an_approval_never_carries_over_to_a_new_head() {
8001 crate::run::pin_test_home();
8002 let mut state = run_state();
8003 state.config.graph.land_approval = true;
8004 let pr = green_pr();
8005 let store = ask::Questions::open();
8006
8007 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
8008 .await
8009 .unwrap();
8010 let mut q = store
8011 .list()
8012 .into_iter()
8013 .find(|q| q.run == state.id)
8014 .unwrap();
8015 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8016 store.put(&mut q).unwrap();
8017 assert_eq!(
8018 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
8019 .await
8020 .unwrap(),
8021 ApprovalGate::Approved,
8022 "the same head keeps its approval"
8023 );
8024
8025 assert_eq!(
8027 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8028 .await
8029 .unwrap(),
8030 ApprovalGate::Pending
8031 );
8032 let all: Vec<_> = store
8033 .list()
8034 .into_iter()
8035 .filter(|q| q.run == state.id)
8036 .collect();
8037 assert_eq!(all.len(), 2);
8038
8039 state.land_approval = None;
8041 assert_eq!(
8042 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
8043 .await
8044 .unwrap(),
8045 ApprovalGate::Pending
8046 );
8047 let open = store
8048 .list()
8049 .into_iter()
8050 .filter(|q| q.run == state.id && q.status.open())
8051 .count();
8052 assert_eq!(open, 1, "the superseded question was retired");
8053 }
8054
8055 #[tokio::test]
8056 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
8057 let mut state = landing_state();
8058 let forge = Scripted::new(
8059 vec![
8060 seen("a", Checks::Green, "CLEAN", false),
8061 seen("b", Checks::Green, "CLEAN", false),
8063 ],
8064 vec![(true, "")],
8065 );
8066 land_with(&mut state, URL, &forge).await.unwrap();
8067 let argvs = forge.argvs();
8068 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
8069 assert!(!has(&argvs[0], "--auto"));
8070 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
8071 assert_eq!(state.status, RunStatus::Merged);
8072 }
8073
8074 fn passing(label: &str) -> CheckInfo {
8075 CheckInfo {
8076 label: label.to_owned(),
8077 verdict: Verdict::Pass,
8078 required: Some(false),
8079 run: None,
8080 }
8081 }
8082
8083 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
8084 (label.to_owned(), run.map(str::to_owned))
8085 }
8086
8087 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
8088 LandReruns {
8089 head: head.to_owned(),
8090 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
8091 }
8092 }
8093
8094 #[test]
8095 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
8096 let f = [failing("test (macos)", Some("7"))];
8097 assert_eq!(
8098 rerun_step(&f, None, "h", 100),
8099 RerunStep::Rerun(vec!["7".to_owned()])
8100 );
8101 let rec = reran("h", &[("7", 100)]);
8102 let grace = crate::release_watch::RERUN_GRACE;
8103 assert_eq!(
8104 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
8105 RerunStep::Grace
8106 );
8107 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
8108 }
8109
8110 #[test]
8111 fn a_failure_without_a_run_id_goes_to_the_fixer() {
8112 let f = [failing("a", Some("7")), failing("ci/external", None)];
8113 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
8114 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
8115 }
8116
8117 #[test]
8118 fn a_moved_head_forgets_the_reruns() {
8119 let f = [failing("a", Some("7"))];
8120 let rec = reran("old", &[("7", 100)]);
8121 assert_eq!(
8122 rerun_step(&f, Some(&rec), "new", 101),
8123 RerunStep::Rerun(vec!["7".to_owned()])
8124 );
8125 }
8126
8127 #[test]
8128 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
8129 let f = [
8130 failing("a", Some("7")),
8131 failing("b", Some("7")),
8132 failing("c", Some("8")),
8133 ];
8134 assert_eq!(
8135 rerun_step(&f, None, "h", 0),
8136 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
8137 );
8138 let rec = reran("h", &[("7", 0)]);
8139 assert_eq!(
8140 rerun_step(&f, Some(&rec), "h", 1),
8141 RerunStep::Rerun(vec!["8".to_owned()])
8142 );
8143 }
8144
8145 #[test]
8146 fn only_blocking_failures_are_candidates_for_a_rerun() {
8147 let check = |label: &str, verdict, required, run: &str| CheckInfo {
8148 label: label.to_owned(),
8149 verdict,
8150 required,
8151 run: Some(run.to_owned()),
8152 };
8153 let got = rerunnable(&[
8154 check("passed", Verdict::Pass, Some(true), "1"),
8155 check("optional", Verdict::Fail, Some(false), "2"),
8156 check("required", Verdict::Fail, Some(true), "3"),
8157 check("unknown", Verdict::Fail, None, "4"),
8158 ]);
8159 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
8160 assert_eq!(labels, ["required", "unknown"]);
8161 }
8162
8163 fn red(head: &str, run: Option<&str>) -> Seen {
8164 let mut s = seen(head, Checks::Red, "BLOCKED", false);
8165 s.pr.failing = vec!["test (macos)".to_owned()];
8166 s.contexts = vec![CheckInfo {
8167 label: "test (macos)".to_owned(),
8168 verdict: Verdict::Fail,
8169 required: Some(true),
8170 run: run.map(str::to_owned),
8171 }];
8172 s
8173 }
8174
8175 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
8176
8177 #[tokio::test]
8178 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
8179 let mut state = landing_state();
8180 let forge = Scripted::new(
8181 vec![
8182 red("a", Some("7")),
8183 seen("a", Checks::Pending, "BLOCKED", false),
8184 seen("a", Checks::Green, "CLEAN", false),
8185 ],
8186 vec![(true, "")],
8187 );
8188 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8189 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8190 assert!(!forge.calls().contains(&"fix"));
8191 assert_eq!(state.status, RunStatus::Merged);
8192 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8193 let argvs = forge.argvs();
8194 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
8195 assert!(
8196 state
8197 .events
8198 .iter()
8199 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
8200 );
8201 }
8202
8203 #[tokio::test]
8204 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
8205 let mut state = landing_state();
8206 let forge = Scripted::new(
8207 vec![
8208 red("a", Some("7")),
8209 seen("a", Checks::Green, "CLEAN", false),
8210 ],
8211 vec![(true, "")],
8212 );
8213 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8214 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8216 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
8217 let calls = forge.calls();
8218 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
8219 let fix_at = calls.iter().position(|c| *c == "fix");
8220 assert!(fix_at.is_none_or(|f| f > rerun_at));
8221 }
8222
8223 #[tokio::test]
8224 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
8225 let mut state = landing_state();
8226 let mut v = red("a", Some("7"));
8227 v.pr.checks = Checks::Pending;
8228 v.contexts.push(CheckInfo {
8229 label: "slow".to_owned(),
8230 verdict: Verdict::Pending,
8231 required: Some(true),
8232 run: Some("8".to_owned()),
8233 });
8234 let forge = Scripted::new(
8235 vec![v, seen("a", Checks::Green, "CLEAN", false)],
8236 vec![(true, "")],
8237 );
8238 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8239 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8240 assert_eq!(state.status, RunStatus::Merged);
8241 }
8242
8243 #[test]
8244 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
8245 let mut v = red("a", Some("7"));
8246 v.contexts.push(CheckInfo {
8247 label: "same run, still going".to_owned(),
8248 verdict: Verdict::Pending,
8249 required: Some(true),
8250 run: Some("7".to_owned()),
8251 });
8252 assert!(rerunnable(&v.contexts).is_empty());
8253 }
8254
8255 #[tokio::test]
8256 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
8257 let mut state = landing_state();
8258 state.config.graph.land_rounds = 0;
8259 let forge = Scripted::new(
8260 vec![
8261 red("a", Some("7")),
8262 seen("a", Checks::Green, "CLEAN", false),
8263 ],
8264 vec![(true, "")],
8265 );
8266 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8267 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8268 assert_eq!(state.status, RunStatus::Merged);
8269 assert!(!forge.calls().contains(&"fix"));
8270 assert_eq!(state.pr.as_ref().unwrap().round, 0);
8271 }
8272
8273 #[tokio::test]
8274 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
8275 let mut state = landing_state();
8276 state.config.graph.land_rounds = 1;
8277 let forge = Scripted::new(
8278 vec![
8279 seen("a", Checks::Green, "CLEAN", true),
8280 red("b", Some("7")),
8281 seen("b", Checks::Green, "CLEAN", false),
8282 ],
8283 vec![(true, "")],
8284 );
8285 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
8286 head: "b".to_owned(),
8287 });
8288 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8289 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
8290 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
8291 assert_eq!(state.pr.as_ref().unwrap().round, 1);
8292 assert_eq!(state.status, RunStatus::Merged);
8293 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
8294 assert_eq!(
8295 value_of(&forge.argvs()[0], "--match-head-commit"),
8296 Some("b")
8297 );
8298 }
8299
8300 #[tokio::test]
8301 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
8302 let mut state = landing_state();
8303 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8304 *forge.rerun_fails.lock().unwrap() = true;
8305 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8306 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8307 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
8308 assert_eq!(
8309 forge.calls().iter().filter(|c| **c == "fix").count(),
8310 1,
8311 "{:?}",
8312 forge.calls()
8313 );
8314 }
8315
8316 #[tokio::test]
8317 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
8318 let mut state = landing_state();
8319 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8320 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
8322 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8323 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8324 assert!(forge.reruns.lock().unwrap().is_empty());
8325 }
8326
8327 #[tokio::test]
8328 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
8329 let mut state = landing_state();
8330 state.config.graph.rerun_flaky_checks = false;
8331 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
8332 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8333 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8334 assert!(forge.reruns.lock().unwrap().is_empty());
8335 assert!(forge.calls().contains(&"fix"));
8336
8337 let mut state = landing_state();
8338 let forge = Scripted::new(vec![red("a", None)], vec![]);
8339 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
8340 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
8341 assert!(forge.reruns.lock().unwrap().is_empty());
8342 assert!(forge.calls().contains(&"fix"));
8343 }
8344
8345 fn names(xs: &[&str]) -> BTreeSet<String> {
8346 xs.iter().map(|x| (*x).to_owned()).collect()
8347 }
8348
8349 #[test]
8350 fn a_required_check_the_rollup_never_listed_is_named() {
8351 let req = names(&["build"]);
8352 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
8353 assert!(why.contains("never reported: build"), "{why}");
8354 assert!(!why.contains("probably waiting for a review"), "{why}");
8355 }
8356
8357 #[test]
8358 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
8359 let why = waiting_on("BLOCKED", &[passing("review")], None);
8360 assert!(why.contains("could not be read"), "{why}");
8361 assert!(!why.contains("probably waiting for a review"), "{why}");
8362 }
8363
8364 #[test]
8365 fn all_required_reported_keeps_the_review_guess() {
8366 let req = names(&["build"]);
8367 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8368 assert!(why.contains("probably waiting for a review"), "{why}");
8369 assert!(!why.contains("never reported"), "{why}");
8370 }
8371
8372 #[test]
8373 fn required_names_match_the_rollup_ignoring_case_only() {
8374 let req = names(&["Build"]);
8375 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
8376 assert!(!why.contains("never reported"), "{why}");
8377 }
8378
8379 #[test]
8380 fn required_contexts_are_read_from_protection_and_rulesets() {
8381 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
8382 assert_eq!(
8383 parse_classic_required(classic),
8384 Some(names(&["build", "lint"]))
8385 );
8386 let rules = r#"[{"type":"pull_request","parameters":{}},
8387 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
8388 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
8389 assert_eq!(parse_ruleset_required("nope"), None);
8390 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
8391 }
8392
8393 #[test]
8394 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
8395 let shown = BTreeSet::new();
8396 let ok = seen("a", Checks::Green, "CLEAN", false);
8397 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
8398 assert!(guard(Some(&ok)));
8399 assert!(!guard(None));
8400 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
8401 let mut stale = ok.clone();
8402 stale.rollup_head = "old".to_owned();
8403 assert!(!guard(Some(&stale)));
8404 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
8405 assert!(!guard(Some(&merged_view("a"))));
8406 }
8407
8408 #[test]
8409 fn the_rollup_node_carries_whether_each_check_is_required() {
8410 let node = node_json("new", "SUCCESS", false)
8411 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
8412 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
8413 assert_eq!(s.contexts.len(), 1);
8414 assert_eq!(s.contexts[0].required, Some(true));
8415 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
8416 assert_eq!(s.contexts[0].required, None);
8417 }
8418
8419 #[tokio::test]
8420 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
8421 let mut state = landing_state();
8422 state.land_armed_head = Some("a".to_owned());
8423 let forge = Scripted::new(
8424 vec![seen("a", Checks::Green, "CLEAN", true)],
8425 vec![(false, "disable exploded")],
8426 );
8427 land_with(&mut state, URL, &forge).await.unwrap();
8428 assert!(!forge.calls().contains(&"fix"));
8429 assert_eq!(state.status, RunStatus::Blocked);
8430 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
8431 let why = state.merge.as_ref().unwrap().detail.clone();
8432 assert!(why.contains("disable exploded"), "{why}");
8433 }
8434}