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