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