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::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{ContestedHandoff, LandApproval, MergeOutcome, RunState, RunStatus, tail};
58
59pub const POLL: Duration = Duration::from_secs(30);
65
66pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86const LOG_TAIL: usize = 4_000;
89
90const MAX_LOGS: usize = 3;
93
94pub const MARKER: &str = "<!-- magi:land -->";
100
101const NOT_A_REVIEW: [&str; 3] = [
110 "skip review by coderabbit.ai",
111 "summarize by coderabbit.ai",
112 "<!-- tips_start -->",
113];
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118 Open,
120 Merged,
122 Closed,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129 Pending,
131 Green,
134 Red,
136 Unknown,
138}
139
140impl PrLifecycle {
141 pub fn as_str(self) -> &'static str {
143 match self {
144 Self::Open => "open",
145 Self::Merged => "merged",
146 Self::Closed => "closed",
147 }
148 }
149}
150
151impl Checks {
152 pub fn as_str(self) -> &'static str {
154 match self {
155 Self::Pending => "pending",
156 Self::Green => "green",
157 Self::Red => "red",
158 Self::Unknown => "unknown",
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166 pub author: String,
168 pub path: Option<String>,
170 pub line: Option<u64>,
172 pub body: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179 pub url: String,
181 pub number: u64,
183 pub state: PrLifecycle,
185 pub checks: Checks,
187 pub failing: Vec<String>,
189 pub review_comments: Vec<ReviewComment>,
191 pub blocking: Blocking,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209 No,
211 Yes,
213 Conflict,
215 Unsaid,
219}
220
221impl Blocking {
222 fn of(raw: &str) -> Self {
224 match raw.to_ascii_uppercase().as_str() {
225 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228 "DIRTY" => Self::Conflict,
229 "" | "UNKNOWN" => Self::Unsaid,
230 _ => Self::Yes,
232 }
233 }
234
235 #[must_use]
237 pub fn stops_a_merge(self) -> bool {
238 !matches!(self, Self::No)
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245 Wait,
247 Rebase,
255 Fix {
257 reason: String,
259 },
260 Merge,
262 Done {
264 merged: bool,
266 },
267 GiveUp {
269 reason: String,
271 },
272}
273
274pub(crate) fn merged_after_all(
296 argv: &[String],
297 stderr: &str,
298 after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300 if after? != PrLifecycle::Merged {
301 return None;
302 }
303 Some(MergeOutcome {
304 mode: MergeMode::Pr,
305 ok: true,
306 detail: format!(
307 "gh {} (the command reported `{}`, but the pull request is merged)",
308 argv.join(" "),
309 stderr.trim()
310 ),
311 empty: false,
312 })
313}
314
315pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
335 decide_with(pr, round, budget, waited, CiExpectation::Expected)
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum CiExpectation {
341 Expected,
344 Absent,
349}
350
351pub fn decide_with(
356 pr: &PrState,
357 round: usize,
358 budget: usize,
359 waited: Duration,
360 ci: CiExpectation,
361) -> Step {
362 match pr.state {
363 PrLifecycle::Merged => return Step::Done { merged: true },
364 PrLifecycle::Closed => return Step::Done { merged: false },
365 PrLifecycle::Open => {}
366 }
367
368 if pr.blocking == Blocking::Conflict {
371 return Step::Rebase;
372 }
373
374 if ci == CiExpectation::Absent {
375 return Step::Merge;
376 }
377
378 let spent = round >= budget;
379 match pr.checks {
380 Checks::Pending => Step::Wait,
381 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
382 Checks::Unknown => Step::GiveUp {
383 reason: format!(
384 "no check status is readable on the pull request after {} minute(s); \
385 refusing to merge on a guess",
386 CHECKS_GRACE.as_secs() / 60
387 ),
388 },
389 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
396 Checks::Red => {
397 let what = format!(
398 "{} check(s) failing: {}",
399 pr.failing.len(),
400 pr.failing.join(", ")
401 );
402 if spent {
403 Step::GiveUp {
404 reason: format!("{what} — still red after {budget} fix round(s)"),
405 }
406 } else {
407 Step::Fix { reason: what }
408 }
409 }
410 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
411 Checks::Green => {
412 let what = format!(
413 "checks are green but {} review comment(s) are unresolved: {}",
414 pr.review_comments.len(),
415 authors(&pr.review_comments)
416 );
417 if spent {
418 Step::GiveUp {
419 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
420 }
421 } else {
422 Step::Fix { reason: what }
423 }
424 }
425 }
426}
427
428fn authors(comments: &[ReviewComment]) -> String {
430 let mut seen: Vec<&str> = Vec::new();
431 for c in comments {
432 if !seen.contains(&c.author.as_str()) {
433 seen.push(&c.author);
434 }
435 }
436 seen.join(", ")
437}
438
439pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
443 vec![
444 "pr".to_owned(),
445 "merge".to_owned(),
446 number.to_string(),
447 "--squash".to_owned(),
448 "--delete-branch".to_owned(),
449 "--subject".to_owned(),
450 subject.to_owned(),
451 ]
452}
453
454pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
460 let mut argv = merge_argv(number, subject);
461 argv.push("--match-head-commit".to_owned());
462 argv.push(head.to_owned());
463 argv
464}
465
466pub fn disable_automerge_argv(number: u64) -> Vec<String> {
471 ["pr", "merge", &number.to_string(), "--disable-auto"]
472 .map(str::to_owned)
473 .to_vec()
474}
475
476fn direct_merge_is_safe(
482 fresh: Option<&Seen>,
483 approved_head: &str,
484 shown: &BTreeSet<String>,
485 round: usize,
486 budget: usize,
487 waited: Duration,
488) -> bool {
489 let Some(fresh) = fresh else {
490 return false;
491 };
492 if fresh.pr.state != PrLifecycle::Open {
493 return false;
494 }
495 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
496 return false;
497 };
498 if !bound.eq_ignore_ascii_case(approved_head) {
499 return false;
500 }
501 let mut pr = fresh.pr.clone();
502 pr.review_comments.retain(|c| !shown.contains(&c.body));
503 decide(&pr, round, budget, waited) == Step::Merge
504}
505
506fn waiting_on(
513 merge_state: &str,
514 contexts: &[CheckInfo],
515 required_set: Option<&BTreeSet<String>>,
516) -> String {
517 let state = if merge_state.is_empty() {
518 "unknown"
519 } else {
520 merge_state
521 };
522 let tag = |c: &CheckInfo| match c.verdict {
523 Verdict::Fail => "failed",
524 _ => "pending",
525 };
526 let unsettled: Vec<&CheckInfo> = contexts
527 .iter()
528 .filter(|c| c.verdict != Verdict::Pass)
529 .collect();
530 let required: Vec<String> = unsettled
531 .iter()
532 .filter(|c| c.required == Some(true))
533 .map(|c| format!("{} ({})", c.label, tag(c)))
534 .collect();
535 let unknown: Vec<String> = unsettled
536 .iter()
537 .filter(|c| c.required.is_none())
538 .map(|c| format!("{} ({})", c.label, tag(c)))
539 .collect();
540 let never: Vec<&str> = required_set
544 .map(|set| {
545 set.iter()
546 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
547 .map(String::as_str)
548 .collect()
549 })
550 .unwrap_or_default();
551 let mut out = format!("merge state: {state}");
552 if !never.is_empty() {
553 let _ = write!(
554 out,
555 "; required checks never reported: {}",
556 never.join(", ")
557 );
558 }
559 if !required.is_empty() {
560 let _ = write!(
561 out,
562 "; required checks not passing: {}",
563 required.join(", ")
564 );
565 }
566 if !unknown.is_empty() {
567 let _ = write!(
568 out,
569 "; whether these are required could not be read, so they may be: {}",
570 unknown.join(", ")
571 );
572 }
573 if required.is_empty() && unknown.is_empty() && never.is_empty() {
574 if required_set.is_some() {
575 out.push_str(
576 "; no required check is pending, failing or unreported, so GitHub is probably \
577 waiting for a review or another branch rule",
578 );
579 } else {
580 out.push_str(
581 "; the required check list could not be read, so a required check that was \
582 never reported cannot be ruled out",
583 );
584 }
585 }
586 out
587}
588
589pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
596 let title = pr_title.trim();
597 if !title.is_empty() && !title.starts_with("magi: candidate") {
598 return title.to_owned();
599 }
600 let first = instruction
601 .lines()
602 .map(str::trim)
603 .find(|l| !l.is_empty())
604 .unwrap_or("magi: land the winning candidate");
605 first.trim_start_matches(['#', ' ']).to_owned()
606}
607
608pub const APPROVE: &str = "merge";
610
611pub const HOLD: &str = "hold";
613
614pub fn merge_intent(message: &str, quote: &str) -> bool {
625 let quote = quote.trim();
626 if quote.is_empty() {
627 return false;
628 }
629 if !message.contains(quote) {
630 return false;
631 }
632 let lower = quote.to_lowercase();
633 if !(lower.contains("merge") || quote.contains("マージ")) {
634 return false;
635 }
636 if hedged(message) {
640 return false;
641 }
642 !retracts(message)
643}
644
645fn hedged(text: &str) -> bool {
649 const WORDS: &[&str] = &[
650 "maybe",
651 "probably",
652 "perhaps",
653 "might",
654 "if",
655 "unless",
656 "not",
657 "no",
658 "nope",
659 "stop",
660 "dont",
661 "abort",
662 "revert",
663 "undo",
664 "never",
665 "wait",
666 "hold",
667 "cancel",
668 "but",
669 "think",
670 "guess",
671 "suppose",
672 "unsure",
673 "yet",
674 "except",
675 "only",
676 "cannot",
677 "should",
678 "once",
679 "provided",
680 "providing",
681 "when",
682 "whenever",
683 "after",
684 "until",
685 "before",
686 "assuming",
687 "given",
688 "while",
689 "whether",
690 "depending",
691 "pending",
692 ];
693 const JA: &[&str] = &[
694 "かも",
695 "たぶん",
696 "多分",
697 "なら",
698 "たら",
699 "ちょっと待",
700 "しないで",
701 "しない",
702 "保留",
703 "まだ",
704 "ただし",
705 "やめ",
706 "いや",
707 "止め",
708 "だめ",
709 "ダメ",
710 "じゃない",
711 "ではない",
712 "ですか",
713 "かな",
714 "でしょう",
715 "思う",
716 "でも",
717 "けど",
718 "ただ",
719 "次第",
720 "場合",
721 "限り",
722 "条件",
723 "とき",
724 "まで",
725 "後で",
726 ];
727 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
728 return true;
729 }
730 text.to_lowercase()
731 .replace('\u{2019}', "'")
732 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
733 .filter(|w| !w.is_empty())
734 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
735}
736
737fn retracts(message: &str) -> bool {
740 let lower = message.to_lowercase();
741 [
742 "やっぱ",
743 "待って",
744 "撤回",
745 "never mind",
746 "actually",
747 "on second thought",
748 ]
749 .iter()
750 .any(|w| lower.contains(w))
751 || lower
752 .split(|c: char| !c.is_ascii_alphabetic())
753 .any(|w| w == "wait")
754}
755
756pub const APPROVAL_NODE: &str = "land-approval";
762
763pub const DIFF_MAX_LINES: usize = 400;
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774pub enum Approval {
775 Merge,
777 Hold,
779}
780
781pub fn approval(answer: Option<&str>) -> Approval {
789 match answer {
790 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
791 _ => Approval::Hold,
792 }
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797enum ApprovalGate {
798 Approved,
800 Held,
803 Pending,
805}
806
807fn esc(s: &str) -> String {
817 let mut out = String::with_capacity(s.len());
818 for c in s.chars() {
819 match c {
820 '&' => out.push_str("&"),
821 '<' => out.push_str("<"),
822 '>' => out.push_str(">"),
823 '"' => out.push_str("""),
824 '\'' => out.push_str("'"),
825 _ => out.push(c),
826 }
827 }
828 out
829}
830
831#[derive(Debug, Clone, PartialEq, Eq)]
833struct StatRow {
834 path: String,
835 added: Option<u64>,
837 removed: Option<u64>,
838}
839
840impl StatRow {
841 fn churn(&self) -> u64 {
844 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
845 }
846}
847
848fn parse_numstat(numstat: &str) -> Vec<StatRow> {
854 let mut rows: Vec<StatRow> = numstat
855 .lines()
856 .filter_map(|line| {
857 let mut parts = line.splitn(3, '\t');
858 let added = parts.next()?.trim();
859 let removed = parts.next()?.trim();
860 let path = parts.next()?.trim();
861 if path.is_empty() {
862 return None;
863 }
864 Some(StatRow {
865 path: path.to_owned(),
866 added: added.parse().ok(),
867 removed: removed.parse().ok(),
868 })
869 })
870 .collect();
871 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
874 rows
875}
876
877fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
886 if line.starts_with("+++") || line.starts_with("---") {
887 (" ", "color:#57606a;font-weight:600", line)
888 } else if let Some(body) = line.strip_prefix('+') {
889 ("+", "background:#e6ffec;color:#0a3622", body)
890 } else if let Some(body) = line.strip_prefix('-') {
891 ("-", "background:#ffebe9;color:#5c1a17", body)
892 } else if line.starts_with("@@") {
893 ("~", "background:#eef2ff;color:#3730a3", line)
894 } else if let Some(body) = line.strip_prefix(' ') {
895 (" ", "", body)
896 } else {
897 (" ", "color:#57606a;font-weight:600", line)
898 }
899}
900
901struct Words {
910 html_lang: &'static str,
911 task: &'static str,
912 what_changed: &'static str,
913 review_verdict: &'static str,
914 reviewer: &'static str,
915 reviewer_no_answer: &'static str,
916 checks: &'static str,
917 nothing_failing: &'static str,
918 files_changed: &'static str,
919 commits: &'static str,
920 no_commits: &'static str,
921 comments: &'static str,
922 no_comments: &'static str,
923 diff: &'static str,
924 truncated: &'static str,
925 lands_as: &'static str,
926}
927
928const EN: Words = Words {
929 html_lang: "en",
930 task: "Task",
931 what_changed: "What changed",
932 review_verdict: "Review verdict",
933 reviewer: "Reviewer",
934 reviewer_no_answer: "produced no answer",
935 checks: "Checks",
936 nothing_failing: "Nothing failing.",
937 files_changed: "file(s) changed",
938 commits: "Commits being squashed",
939 no_commits: "No commit subjects could be read from the branch.",
940 comments: "Review comments",
941 no_comments: "Nothing outstanding at this observation.",
942 diff: "Diff",
943 truncated: "Truncated",
944 lands_as: "They land as one commit titled",
945};
946
947const JA: Words = Words {
948 html_lang: "ja",
949 task: "タスク",
950 what_changed: "変更内容",
951 review_verdict: "レビューの結論",
952 reviewer: "レビュアー",
953 reviewer_no_answer: "回答なし",
954 checks: "チェック",
955 nothing_failing: "失敗しているものはありません。",
956 files_changed: "ファイル変更",
957 commits: "squash されるコミット",
958 no_commits: "ブランチからコミット件名を読めませんでした。",
959 comments: "レビューコメント",
960 no_comments: "この時点で未対応のものはありません。",
961 diff: "差分",
962 truncated: "省略",
963 lands_as: "これらは次の件名の1コミットとして入ります:",
964};
965
966impl Words {
967 fn lands_as_tail(&self) -> &'static str {
971 if self.html_lang == "ja" {
972 "。この件名も承認の対象です。"
973 } else {
974 ", which you are approving too."
975 }
976 }
977
978 fn approval_summary(&self, number: u64, subject: &str) -> String {
980 if self.html_lang == "ja" {
981 format!("プルリクエスト #{number} をマージ: {subject}")
982 } else {
983 format!("merge pull request #{number}: {subject}")
984 }
985 }
986
987 fn approval_detail(
989 &self,
990 url: &str,
991 base: &str,
992 subject: &str,
993 contested: Option<&ContestedHandoff>,
994 ) -> String {
995 let body = if self.html_lang == "ja" {
996 format!(
997 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
998 できる状態です。差分の要約・パッチ・squash されるコミットは\
999 下のパネルにあります。"
1000 )
1001 } else {
1002 format!(
1003 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1004 The panel holds the diffstat, the patch and the commits being squashed."
1005 )
1006 };
1007 match contested {
1008 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1009 None => body,
1010 }
1011 }
1012
1013 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1016 const SHOWN: usize = 5;
1017 const TITLE_CHARS: usize = 100;
1018 let ja = self.html_lang == "ja";
1019 let mut out = if ja {
1020 format!(
1021 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1022 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1023 )
1024 } else {
1025 format!(
1026 "{url} is held for approval although merge approvals are off: the \
1027 review ended with blocking findings still open and a reviewer \
1028 voting reject.\n"
1029 )
1030 };
1031 for f in c.findings.iter().take(SHOWN) {
1032 let at = match (&f.file, f.line) {
1033 (Some(file), Some(line)) => format!("{file}:{line}"),
1034 (Some(file), None) => file.clone(),
1035 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1036 };
1037 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1038 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1039 }
1040 if c.findings.len() > SHOWN {
1041 let more = c.findings.len() - SHOWN;
1042 let _ = writeln!(
1043 out,
1044 "{}",
1045 if ja {
1046 format!("- ほか {more} 件")
1047 } else {
1048 format!("- and {more} more")
1049 }
1050 );
1051 }
1052 let seats: Vec<String> = c
1053 .rejecters
1054 .iter()
1055 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1056 .collect();
1057 let _ = write!(
1058 out,
1059 "{} {}",
1060 if ja {
1061 "却下したレビュアー:"
1062 } else {
1063 "Rejected by reviewer:"
1064 },
1065 seats.join(", ")
1066 );
1067 out
1068 }
1069
1070 fn truncated_note(
1072 &self,
1073 omitted: usize,
1074 total: usize,
1075 shown: usize,
1076 where_: &str,
1077 base: &str,
1078 head: &str,
1079 ) -> String {
1080 if self.html_lang == "ja" {
1081 format!(
1082 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1083 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1084 プルリクエストにあります。"
1085 )
1086 } else {
1087 format!(
1088 "{omitted} of {total} diff lines omitted after the first {shown}. \
1089 The whole patch is in <code>{where_}</code> \
1090 (<code>git diff {base}...{head}</code>) and on the pull request."
1091 )
1092 }
1093 }
1094}
1095
1096fn words(language: &str) -> &'static Words {
1099 if crate::lang::is_japanese(language) {
1100 &JA
1101 } else {
1102 &EN
1103 }
1104}
1105
1106pub fn approval_panel(
1118 state: &RunState,
1119 pr: &PrState,
1120 diffstat: &str,
1121 diff: &str,
1122 commits: &[String],
1123 subject: &str,
1124) -> String {
1125 let rows = parse_numstat(diffstat);
1126 let w = words(&state.config.graph.language);
1127 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1128
1129 let _ = writeln!(
1130 h,
1131 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1132 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1133 w.html_lang
1134 );
1135 let _ = writeln!(
1136 h,
1137 "<title>merge #{} — {}</title>\n</head>",
1138 pr.number,
1139 esc(subject)
1140 );
1141 h.push_str(
1142 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1143 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1144 word-break:break-word\">\n",
1145 );
1146
1147 let _ = writeln!(
1149 h,
1150 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1151 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1152 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1153 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1154 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
1155 pr.number,
1156 esc(&state.base_branch),
1157 esc(subject),
1158 esc(&state.id),
1159 esc(&pr.url),
1160 esc(&pr.url),
1161 );
1162
1163 let _ = writeln!(
1166 h,
1167 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1168 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1169 w.task,
1170 esc(&state.instruction)
1171 );
1172
1173 if let Some(summary) = state
1175 .winner()
1176 .map(|c| c.summary.as_str())
1177 .filter(|s| !s.is_empty())
1178 {
1179 let _ = writeln!(
1180 h,
1181 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1182 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1183 w.what_changed,
1184 esc(summary)
1185 );
1186 }
1187
1188 if let Some(round) = state.reviews.last() {
1191 let _ = writeln!(
1192 h,
1193 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1194 w.review_verdict
1195 );
1196 for r in &round.reviews {
1197 let body = match &r.failed {
1204 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1205 None => esc(&r.summary),
1206 };
1207 let _ = writeln!(
1208 h,
1209 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1210 border-radius:6px\">\
1211 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1212 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1213 w.reviewer,
1214 r.reviewer,
1215 esc(&r.agent),
1216 body,
1217 );
1218 }
1219 }
1220
1221 let _ = writeln!(
1222 h,
1223 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1224 w.checks,
1225 esc(pr.checks.as_str())
1226 );
1227 if pr.failing.is_empty() {
1228 let _ = writeln!(
1229 h,
1230 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1231 w.nothing_failing
1232 );
1233 } else {
1234 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1235 for f in &pr.failing {
1236 let _ = writeln!(h, "<li>{}</li>", esc(f));
1237 }
1238 h.push_str("</ul>\n");
1239 }
1240
1241 let _ = writeln!(
1244 h,
1245 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1246 rows.len(),
1247 w.files_changed
1248 );
1249 h.push_str(
1250 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1251 <thead><tr>\
1252 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1253 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1254 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1255 </th></tr></thead>\n<tbody>\n",
1256 );
1257 let mut total_added = 0u64;
1258 let mut total_removed = 0u64;
1259 for r in &rows {
1260 total_added += r.added.unwrap_or(0);
1261 total_removed += r.removed.unwrap_or(0);
1262 let cell = |n: Option<u64>| match n {
1263 Some(n) => n.to_string(),
1264 None => "bin".to_owned(),
1265 };
1266 let _ = writeln!(
1267 h,
1268 "<tr>\
1269 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1270 font-family:ui-monospace,monospace\">{}</td>\
1271 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1272 color:#0a3622\">{}</td>\
1273 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1274 color:#5c1a17\">{}</td></tr>",
1275 esc(&r.path),
1276 cell(r.added),
1277 cell(r.removed),
1278 );
1279 }
1280 let _ = writeln!(
1281 h,
1282 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1283 <td style=\"padding:4px 2px\">total</td>\
1284 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1285 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1286 </tr></tfoot>\n</table>"
1287 );
1288
1289 let _ = writeln!(
1291 h,
1292 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1293 w.commits
1294 );
1295 if commits.is_empty() {
1296 h.push_str(&format!(
1297 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1298 w.no_commits
1299 ));
1300 } else {
1301 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1302 for c in commits {
1303 let _ = writeln!(h, "<li>{}</li>", esc(c));
1304 }
1305 h.push_str("</ol>\n");
1306 }
1307 let _ = writeln!(
1308 h,
1309 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1310 w.lands_as,
1311 esc(subject),
1312 w.lands_as_tail()
1313 );
1314
1315 let _ = writeln!(
1317 h,
1318 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1319 w.comments
1320 );
1321 if pr.review_comments.is_empty() {
1322 h.push_str(&format!(
1323 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1324 w.no_comments
1325 ));
1326 } else {
1327 for c in &pr.review_comments {
1328 let anchor = match (&c.path, c.line) {
1329 (Some(p), Some(l)) => format!("{p}:{l}"),
1330 (Some(p), None) => p.clone(),
1331 _ => "pull request thread".to_owned(),
1332 };
1333 let _ = writeln!(
1334 h,
1335 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1336 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1337 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1338 esc(&c.author),
1339 esc(&anchor),
1340 esc(&tail(&c.body, 800)),
1341 );
1342 }
1343 }
1344
1345 let total = diff.lines().count();
1347 let shown = total.min(DIFF_MAX_LINES);
1348 let _ = writeln!(
1349 h,
1350 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1351 w.diff
1352 );
1353 h.push_str(
1354 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1355 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1356 );
1357 for line in diff.lines().take(shown) {
1358 let (gutter, style, body) = diff_row(line);
1359 let _ = writeln!(
1360 h,
1361 "<div style=\"display:flex;{style}\">\
1362 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1363 border-right:1px solid #d0d7de\">{gutter}</span>\
1364 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1365 esc(body),
1366 );
1367 }
1368 h.push_str("</div>\n");
1369 if total > shown {
1370 let omitted = total - shown;
1371 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1372 let where_ = state.winner().map_or_else(
1373 || state.repo.display().to_string(),
1374 |w| w.worktree.display().to_string(),
1375 );
1376 let _ = writeln!(
1377 h,
1378 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1379 font-size:13px\">{}: {}</p>",
1380 w.truncated,
1381 w.truncated_note(
1382 omitted,
1383 total,
1384 shown,
1385 &esc(&where_),
1386 &esc(&state.base_branch),
1387 &esc(head),
1388 ),
1389 );
1390 }
1391
1392 h.push_str("</body>\n</html>\n");
1393 h
1394}
1395
1396fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1400 if state.config.graph.hold_contested_merge {
1401 state.contested_handoff.clone()
1402 } else {
1403 None
1404 }
1405}
1406
1407pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1416 let mut s = format!(
1417 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1418 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1419 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1420 request open. Silence is a hold: the owner not answering never merges. Only \
1421 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1422 own words, merges. Hedged, conditional, negated or questioning wording \
1423 (\"maybe\", \"probably\", \"if\", \"いいかも\", \"たぶん\") is not a decision \
1424 and stays a hold.\n\n\
1425 This brief is a snapshot from when you were attached: check `magi show {run}` \
1426 and `gh pr view` (read-only) before telling the owner anything current. \
1427 You run with permission to write the question record, and what keeps you \
1428 from touching anything else is this brief and your instructions - so do \
1429 not change files, branches or the pull request.",
1430 run = q.run
1431 );
1432 let Some(state) = state else {
1433 s.push_str(
1434 "\n\nThe run's record could not be read, so the pull request, the panel \
1435 summary and any contested findings are not known to you beyond the \
1436 question's own text. Say so to the owner rather than guessing.",
1437 );
1438 return s;
1439 };
1440 if let Some(pr) = &state.pr {
1441 s.push_str(&format!(
1442 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1443 pr.number, pr.url, pr.state
1444 ));
1445 }
1446 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1447 if let Some(w) = state.winner() {
1448 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1449 }
1450 match contested_to_ask(state) {
1451 Some(c) => {
1452 s.push_str(
1453 "\n\nThis question was filed although merge approvals are off, because \
1454 the review hand-off is contested. Open findings:",
1455 );
1456 for f in &c.findings {
1457 let at = match (&f.file, f.line) {
1458 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1459 (Some(file), None) => format!(" ({file})"),
1460 _ => String::new(),
1461 };
1462 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1463 }
1464 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1465 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1466 }
1467 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1468 }
1469 s
1470}
1471
1472async fn approval_gate(
1492 state: &mut RunState,
1493 pr: &PrState,
1494 subject: &str,
1495 contested: Option<&ContestedHandoff>,
1496 head: &str,
1497) -> Result<ApprovalGate> {
1498 let store = ask::Questions::open();
1499 let reusable = state
1504 .land_approval
1505 .as_ref()
1506 .filter(|a| a.head.eq_ignore_ascii_case(head))
1507 .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1508 if reusable.is_none() {
1509 for stale in store
1510 .list()
1511 .into_iter()
1512 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1513 {
1514 let why = "the pull request moved to a different head commit; asked again about it";
1515 if let Err(e) = store.update(&stale.id, |q| {
1516 q.abandon(why);
1517 Ok(())
1518 }) {
1519 tracing::warn!("could not retire the superseded approval question: {e:#}");
1520 }
1521 }
1522 }
1523
1524 let q = match reusable {
1525 Some(q) => q,
1526 None => {
1527 let worktree = match state.winner() {
1528 Some(w) => w.worktree.clone(),
1529 None => state.repo.clone(),
1530 };
1531 let head = if head.is_empty() {
1534 state
1535 .winner()
1536 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1537 } else {
1538 head.to_owned()
1539 };
1540 let remote = &state.config.merge.remote;
1543 let tracking = format!("{remote}/{}", state.base_branch);
1544 let base = if git::rev_exists(&worktree, &tracking).await {
1545 tracking
1546 } else {
1547 String::new()
1548 };
1549 let range = format!("{base}...{head}");
1550 let numstat = if base.is_empty() {
1554 String::new()
1555 } else {
1556 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1557 .await
1558 .map(|o| o.stdout)
1559 .unwrap_or_default()
1560 };
1561 let diff = if base.is_empty() {
1562 String::new()
1563 } else {
1564 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1565 };
1566 let commits: Vec<String> = if base.is_empty() {
1567 Vec::new()
1568 } else {
1569 git::git_raw(
1570 &worktree,
1571 &[
1572 "log",
1573 "--reverse",
1574 "--format=%s",
1575 &format!("{base}..{head}"),
1576 ],
1577 )
1578 .await
1579 .map(|o| o.stdout)
1580 .unwrap_or_default()
1581 .lines()
1582 .filter(|l| !l.trim().is_empty())
1583 .map(str::to_owned)
1584 .collect()
1585 };
1586
1587 let w = words(&state.config.graph.language);
1588 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1589 let mut fresh = ask::Question::new(
1590 state.id.clone(),
1591 APPROVAL_NODE.to_owned(),
1592 "land".to_owned(),
1593 w.approval_summary(pr.number, subject),
1594 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1595 vec![APPROVE.to_owned(), HOLD.to_owned()],
1596 );
1597 store
1598 .put_panel(&mut fresh, &html, &[])
1599 .context("write the merge approval panel")?;
1600 store
1601 .put(&mut fresh)
1602 .context("file the merge approval question")?;
1603 state.land_approval = Some(LandApproval {
1604 question: fresh.id.clone(),
1605 head: head.clone(),
1606 });
1607 state.event(
1608 "land",
1609 format!("asking for merge approval ({})", fresh.short()),
1610 );
1611 state.save()?;
1612 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1613 tracing::warn!(
1617 "could not notify about merge approval question {}: {e:#} - \
1618 the web UI is the only surface for it now",
1619 fresh.short()
1620 );
1621 }
1622 fresh
1623 }
1624 };
1625
1626 Ok(match q.status {
1627 ask::QuestionStatus::Open => ApprovalGate::Pending,
1628 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1632 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1636 Approval::Merge => ApprovalGate::Approved,
1637 Approval::Hold => ApprovalGate::Held,
1638 },
1639 })
1640}
1641
1642fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1645 let mut failing = Vec::new();
1646 let mut pending = false;
1647 let mut unknown = false;
1648 for check in rollup {
1649 match check.verdict() {
1650 Verdict::Pass => {}
1651 Verdict::Pending => pending = true,
1652 Verdict::Fail => failing.push(check.label()),
1653 Verdict::Unknown => unknown = true,
1654 }
1655 }
1656 let checks = if rollup.is_empty() {
1657 Checks::Unknown
1658 } else if pending {
1659 Checks::Pending
1660 } else if !failing.is_empty() {
1661 Checks::Red
1662 } else if unknown {
1663 Checks::Unknown
1664 } else {
1665 Checks::Green
1666 };
1667 (checks, failing)
1668}
1669
1670pub fn parse_pr(json: &str) -> Result<PrState> {
1673 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1674 let state = match raw.state.to_ascii_uppercase().as_str() {
1675 "OPEN" => PrLifecycle::Open,
1676 "MERGED" => PrLifecycle::Merged,
1677 "CLOSED" => PrLifecycle::Closed,
1678 other => bail!("unknown pull request state `{other}`"),
1679 };
1680
1681 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1682
1683 let mut review_comments = Vec::new();
1684 for r in raw.reviews {
1685 push_if_outstanding(
1686 &mut review_comments,
1687 ReviewComment {
1688 author: r.author.login,
1689 path: None,
1690 line: None,
1691 body: r.body,
1692 },
1693 );
1694 }
1695 for c in raw.comments {
1696 push_if_outstanding(
1697 &mut review_comments,
1698 ReviewComment {
1699 author: c.author.login,
1700 path: None,
1701 line: None,
1702 body: c.body,
1703 },
1704 );
1705 }
1706
1707 Ok(PrState {
1708 url: raw.url,
1709 number: raw.number,
1710 state,
1711 checks,
1712 failing,
1713 review_comments,
1714 blocking: Blocking::of(&raw.merge_state_status),
1715 })
1716}
1717
1718#[derive(Debug, Clone, PartialEq, Eq)]
1720pub(crate) struct CheckView {
1721 pub name: String,
1722 pub verdict: Verdict,
1723 pub run: Option<String>,
1725 pub url: Option<String>,
1726}
1727
1728#[derive(Debug, Clone, PartialEq, Eq)]
1732pub(crate) struct RollupView {
1733 pub url: String,
1734 pub number: u64,
1735 pub state: PrLifecycle,
1736 pub head: String,
1737 pub checks: Vec<CheckView>,
1738}
1739
1740pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1743 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1744 let state = match raw.state.to_ascii_uppercase().as_str() {
1745 "OPEN" => PrLifecycle::Open,
1746 "MERGED" => PrLifecycle::Merged,
1747 "CLOSED" => PrLifecycle::Closed,
1748 other => bail!("unknown pull request state `{other}`"),
1749 };
1750 let checks = raw
1751 .status_check_rollup
1752 .iter()
1753 .map(|c| CheckView {
1754 name: c.label(),
1755 verdict: c.verdict(),
1756 run: c.url().and_then(run_of),
1757 url: c.url().map(str::to_owned),
1758 })
1759 .collect();
1760 Ok(RollupView {
1761 url: raw.url,
1762 number: raw.number,
1763 state,
1764 head: raw.head_ref_oid,
1765 checks,
1766 })
1767}
1768
1769pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1779 let view = gh(
1780 repo,
1781 &[
1782 "pr".to_owned(),
1783 "view".to_owned(),
1784 pr_url.to_owned(),
1785 "--json".to_owned(),
1786 "state".to_owned(),
1787 ],
1788 )
1789 .await?;
1790 if !view.0 {
1791 bail!("gh pr view {pr_url}: {}", view.1);
1792 }
1793 Ok(parse_pr(&view.1)?.state)
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1803pub struct ExternalMerge {
1804 pub url: String,
1806 pub number: u64,
1808}
1809
1810#[derive(Debug, Deserialize)]
1811#[serde(rename_all = "camelCase")]
1812struct GhMergedPr {
1813 url: String,
1814 number: u64,
1815 merged_at: String,
1816 base_ref_name: String,
1817}
1818
1819fn pick_merged_pr(
1833 json: &str,
1834 base_branch: &str,
1835 created_at: Timestamp,
1836) -> Result<Option<ExternalMerge>> {
1837 let raw: Vec<GhMergedPr> =
1838 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1839 let mut matches: Vec<ExternalMerge> = Vec::new();
1840 for pr in raw {
1841 if pr.base_ref_name != base_branch {
1842 continue;
1843 }
1844 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1845 continue;
1846 };
1847 if merged_at < created_at {
1848 continue;
1849 }
1850 matches.push(ExternalMerge {
1851 url: pr.url,
1852 number: pr.number,
1853 });
1854 }
1855 if matches.len() == 1 {
1856 Ok(matches.pop())
1857 } else {
1858 Ok(None)
1859 }
1860}
1861
1862#[derive(Debug, Clone, PartialEq, Eq)]
1864pub enum OpenPr {
1865 None,
1867 One {
1869 url: String,
1871 title: String,
1873 },
1874 Many(Vec<String>),
1876}
1877
1878#[derive(Debug, Deserialize)]
1879#[serde(rename_all = "camelCase")]
1880struct GhOpenPr {
1881 url: String,
1885 #[serde(default)]
1886 title: String,
1887 base_ref_name: String,
1888}
1889
1890pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1896 let raw: Vec<GhOpenPr> =
1897 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1898 let mut hits: Vec<GhOpenPr> = raw
1899 .into_iter()
1900 .filter(|p| p.base_ref_name == base)
1901 .collect();
1902 Ok(match hits.len() {
1903 0 => OpenPr::None,
1904 1 => {
1905 let p = hits.remove(0);
1906 OpenPr::One {
1907 url: p.url,
1908 title: p.title,
1909 }
1910 }
1911 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1912 })
1913}
1914
1915pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1919 let (ok, out) = gh(
1920 repo,
1921 &[
1922 "pr".to_owned(),
1923 "list".to_owned(),
1924 "--head".to_owned(),
1925 branch.to_owned(),
1926 "--base".to_owned(),
1927 base.to_owned(),
1928 "--state".to_owned(),
1929 "open".to_owned(),
1930 "--json".to_owned(),
1931 "number,url,title,baseRefName".to_owned(),
1932 ],
1933 )
1934 .await?;
1935 if !ok {
1936 bail!("gh pr list failed: {out}");
1937 }
1938 pick_open_pr(&out, base)
1939}
1940
1941#[derive(Debug, Deserialize)]
1942#[serde(rename_all = "camelCase")]
1943struct GhPrHead {
1944 head_ref_name: String,
1945 base_ref_name: String,
1946 state: String,
1947 is_cross_repository: bool,
1950 head_ref_oid: String,
1953}
1954
1955#[derive(Debug, Clone, PartialEq, Eq)]
1957pub struct Refusal {
1958 pub retry: bool,
1961 pub why: String,
1963}
1964
1965impl Refusal {
1966 fn final_(why: String) -> Self {
1967 Self { retry: false, why }
1968 }
1969}
1970
1971pub fn closable(
1980 json: &str,
1981 branch: &str,
1982 base: &str,
1983 verified: &[String],
1984) -> std::result::Result<(), Refusal> {
1985 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1986 retry: true,
1987 why: format!("could not read the pull request ({e})"),
1988 })?;
1989 if pr.head_ref_name != branch {
1990 return Err(Refusal::final_(format!(
1991 "its head is `{}`, not this run's `{branch}`",
1992 pr.head_ref_name
1993 )));
1994 }
1995 if pr.is_cross_repository {
1996 return Err(Refusal::final_("its head lives in a fork".to_owned()));
1997 }
1998 if pr.base_ref_name != base {
1999 return Err(Refusal::final_(format!(
2000 "it targets `{}`, not `{base}`",
2001 pr.base_ref_name
2002 )));
2003 }
2004 if !pr.state.eq_ignore_ascii_case("open") {
2005 return Err(Refusal::final_(format!(
2006 "it is already {}",
2007 pr.state.to_ascii_lowercase()
2008 )));
2009 }
2010 if !verified.contains(&pr.head_ref_oid) {
2014 return Err(Refusal {
2015 retry: true,
2016 why: format!(
2017 "its head {} is not a commit this run checked against the base",
2018 crate::already::short_sha(&pr.head_ref_oid)
2019 ),
2020 });
2021 }
2022 Ok(())
2023}
2024
2025async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2030 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2031 return false;
2032 };
2033 is_forge_url(url.trim())
2034}
2035
2036fn is_forge_url(url: &str) -> bool {
2037 url.contains("://") && !url.starts_with("file://")
2038 || url
2039 .split_once(':')
2040 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2041}
2042
2043fn forge_unavailable(message: &str) -> bool {
2046 message.contains("known GitHub host") || message.contains("spawn gh")
2047}
2048
2049pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2052 let how = match evidence.proof {
2053 crate::already::Proof::PatchId => format!(
2054 "carried by commit {} on `{base}` with the same patch",
2055 evidence.names()
2056 ),
2057 crate::already::Proof::Ancestry => {
2058 format!("already in the history of `{base}` as {}", evidence.names())
2059 }
2060 crate::already::Proof::Tree => format!(
2061 "already part of `{base}` (merging this branch changes nothing at {})",
2062 crate::already::short_sha(&evidence.tip)
2063 ),
2064 };
2065 format!(
2066 "Closing: everything this branch adds is {how}, so there is nothing left to \
2067 land. This pull request was closed automatically after that was verified; \
2068 reopen it if you disagree."
2069 )
2070}
2071
2072pub async fn close_superseded_pr(
2084 state: &mut RunState,
2085 branch: &str,
2086 evidence: &crate::already::Evidence,
2087 verified: &[String],
2088) -> Result<std::result::Result<String, String>> {
2089 let repo = state.repo.clone();
2090 let base = state.base_branch.clone();
2091 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2092 Some(p) => p.url.clone(),
2093 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2098 return Ok(Err(
2099 "the remote is not a forge, so there is no pull request".to_owned(),
2100 ));
2101 }
2102 None => match find_open_pr(&repo, branch, &base).await {
2103 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2104 return Ok(Err(format!("no forge to ask: {e:#}")));
2105 }
2106 Err(e) => return Err(e),
2107 Ok(OpenPr::One { url, .. }) => url,
2108 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2109 Ok(OpenPr::Many(urls)) => {
2110 return Ok(Err(format!(
2111 "{} open pull requests name it; not choosing between them",
2112 urls.len()
2113 )));
2114 }
2115 },
2116 };
2117 let (ok, view) = gh(
2118 &repo,
2119 &[
2120 "pr".to_owned(),
2121 "view".to_owned(),
2122 url.clone(),
2123 "--json".to_owned(),
2124 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2125 ],
2126 )
2127 .await?;
2128 if !ok {
2129 bail!("gh pr view {url} failed: {view}");
2130 }
2131 if let Err(refusal) = closable(&view, branch, &base, verified) {
2132 if refusal.retry {
2135 bail!("left {url} open: {}", refusal.why);
2136 }
2137 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2138 }
2139 let (ok, out) = gh(
2140 &repo,
2141 &[
2142 "pr".to_owned(),
2143 "close".to_owned(),
2144 url.clone(),
2145 "--comment".to_owned(),
2146 superseded_comment(&base, evidence),
2147 ],
2148 )
2149 .await?;
2150 if !ok {
2151 bail!("gh pr close {url} failed: {out}");
2152 }
2153 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2154 p.state = "closed".to_owned();
2155 }
2156 Ok(Ok(url))
2157}
2158
2159pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2163 let (ok, out) = gh(
2164 repo,
2165 &[
2166 "pr".to_owned(),
2167 "edit".to_owned(),
2168 url.to_owned(),
2169 "--title".to_owned(),
2170 title.to_owned(),
2171 ],
2172 )
2173 .await?;
2174 if !ok {
2175 bail!("gh pr edit failed: {out}");
2176 }
2177 Ok(())
2178}
2179
2180pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2193 let Some(winner) = state.winner() else {
2194 return Ok(None);
2195 };
2196 let branch = winner.branch.clone();
2197 let out = gh(
2198 &state.repo,
2199 &[
2200 "pr".to_owned(),
2201 "list".to_owned(),
2202 "--head".to_owned(),
2203 branch.clone(),
2204 "--state".to_owned(),
2205 "merged".to_owned(),
2206 "--json".to_owned(),
2207 "url,number,mergedAt,baseRefName".to_owned(),
2208 ],
2209 )
2210 .await?;
2211 if !out.0 {
2212 bail!("gh pr list --head {branch}: {}", out.1);
2213 }
2214 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2215}
2216
2217pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2229 let out = tokio::process::Command::new("git")
2230 .args(["merge-base", "--is-ancestor", branch, base_branch])
2231 .current_dir(repo)
2232 .quiet()
2233 .stdin(std::process::Stdio::null())
2234 .output()
2235 .await
2236 .context("spawn git merge-base --is-ancestor")?;
2237 Ok(out.status.success())
2238}
2239
2240fn forge_slug(url: &str) -> Option<(String, &str)> {
2248 let rest = url.rsplit("://").next()?;
2249 let (host, path) = rest.split_once('/')?;
2250 if host.is_empty() {
2251 return None;
2252 }
2253 Some((host.to_ascii_lowercase(), path))
2254}
2255
2256pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2264 let (host, path) = forge_slug(url)?;
2265 let mut segments = path.split('/');
2266 let owner = segments.next()?;
2267 let repo = segments.next()?;
2268 let kind = segments.next()?;
2269 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2270 return None;
2271 }
2272 Some(format!("{host}/{owner}/{repo}"))
2273}
2274
2275fn slug_of_repo_url(url: &str) -> Option<String> {
2279 let (host, path) = forge_slug(url)?;
2280 let mut segments = path.split('/');
2281 let owner = segments.next()?;
2282 let repo = segments.next()?;
2283 if owner.is_empty() || repo.is_empty() {
2284 return None;
2285 }
2286 Some(format!("{host}/{owner}/{repo}"))
2287}
2288
2289pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2310 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2311 return Ok(());
2312 }
2313 bail!(
2314 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2315 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2316 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2317 verify by hand before treating this as a false positive)"
2318 );
2319}
2320
2321async fn repo_slug(repo: &Path) -> Result<String> {
2332 let out = gh(
2333 repo,
2334 &[
2335 "repo".to_owned(),
2336 "view".to_owned(),
2337 "--json".to_owned(),
2338 "url".to_owned(),
2339 ],
2340 )
2341 .await?;
2342 if !out.0 {
2343 bail!("gh repo view --json url: {}", out.1);
2344 }
2345 #[derive(Debug, Deserialize)]
2346 struct GhRepo {
2347 url: String,
2348 }
2349 let parsed: GhRepo = serde_json::from_str(&out.1)
2350 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2351 slug_of_repo_url(&parsed.url)
2352 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2353}
2354
2355pub async fn correct_manual_merge(
2403 state: &mut RunState,
2404 url: &str,
2405) -> Result<(RunStatus, RunStatus)> {
2406 let Some(pr_slug) = slug_of_pr_url(url) else {
2407 bail!(
2408 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2409 this pull request belongs to"
2410 );
2411 };
2412 let run_slug = repo_slug(&state.repo).await?;
2413 ensure_same_repo(&run_slug, &pr_slug)?;
2414 correct_merge(state, url).await
2415}
2416
2417pub(crate) async fn correct_confirmed_external_merge(
2428 state: &mut RunState,
2429 url: &str,
2430) -> Result<(RunStatus, RunStatus)> {
2431 correct_merge(state, url).await
2432}
2433
2434fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2436 let Some(pr) = a.pr.as_ref() else {
2437 return false;
2438 };
2439 if !url.is_empty()
2440 && pr
2441 .url
2442 .trim_end_matches('/')
2443 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2444 {
2445 return true;
2446 }
2447 number > 0
2448 && pr.number == number
2449 && match (a.repo.canonicalize(), repo.canonicalize()) {
2450 (Ok(x), Ok(y)) => x == y,
2451 _ => a.repo == repo,
2452 }
2453}
2454
2455fn rewrite_open_prs(
2468 home: &Path,
2469 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2470) -> usize {
2471 let now = Timestamp::now();
2472 let mut changed = 0;
2473 for id in crate::run::list_ids_in(&home.join("runs")) {
2474 let path = home.join("runs").join(&id).join("run.json");
2475 let Ok(body) = std::fs::read_to_string(&path) else {
2476 continue;
2477 };
2478 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2479 continue;
2480 };
2481 if !state.status.done()
2482 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2483 || crate::daemon::is_working_on(home, &id, now)
2484 {
2485 continue;
2486 }
2487 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2488 continue;
2489 };
2490 if let Some(pr) = state.pr.as_mut() {
2491 pr.state = to.as_str().to_owned();
2492 }
2493 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2494 state.event(
2495 "land",
2496 format!("recorded {url} as {}: another run settled it", to.as_str()),
2497 );
2498 match state.save_under(home) {
2499 Ok(()) => changed += 1,
2500 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2501 }
2502 }
2503 changed
2504}
2505
2506pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2511 if to == PrLifecycle::Open {
2512 return;
2513 }
2514 let Some(home) = crate::run::try_home() else {
2515 return;
2516 };
2517 write_pr_state_through_in(&home, run, to);
2518}
2519
2520pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2521 let Some(pr) = run.pr.as_ref() else {
2522 return 0;
2523 };
2524 let (url, number) = (pr.url.clone(), pr.number);
2525 rewrite_open_prs(home, &mut |other| {
2526 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2527 })
2528}
2529
2530pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2533 let now = Timestamp::now();
2534 let mut out = Vec::new();
2535 for id in crate::run::list_ids_in(&home.join("runs")) {
2536 let path = home.join("runs").join(&id).join("run.json");
2537 let Ok(body) = std::fs::read_to_string(&path) else {
2538 continue;
2539 };
2540 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2541 continue;
2542 };
2543 if let Some(pr) = state.pr.as_ref()
2544 && state.status.done()
2545 && pr.state == "open"
2546 && !pr.url.is_empty()
2547 && !crate::daemon::is_working_on(home, &id, now)
2548 {
2549 out.push((id, state.repo.clone(), pr.url.clone()));
2550 }
2551 }
2552 out
2553}
2554
2555pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2558 rewrite_open_prs(home, &mut |s| {
2559 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2560 })
2561}
2562
2563pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2570 let mut known = BTreeMap::new();
2571 let mut failed = 0;
2572 let mut seen = BTreeSet::new();
2573 for (_, repo, url) in stale_open_prs(home) {
2574 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2575 continue;
2576 }
2577 match lifecycle(&repo, &url).await {
2578 Ok(state) => {
2579 known.insert(url, state);
2580 }
2581 Err(e) => {
2582 tracing::warn!("repair pr state of {url}: {e:#}");
2583 failed += 1;
2584 }
2585 }
2586 }
2587 (apply_pr_states(home, &known), failed)
2588}
2589
2590async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2591 match lifecycle(&state.repo, url).await? {
2592 PrLifecycle::Merged => {}
2593 other => bail!(
2594 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2595 other.as_str(),
2596 state.id
2597 ),
2598 }
2599 let before = state.status;
2600 if let Err(e) = land(state, url).await {
2601 state.status = RunStatus::Blocked;
2608 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2609 state.save()?;
2610 return Err(e).context(format!("confirming the merge of {url}"));
2611 }
2612 state.event(
2613 "fold",
2614 "operator recorded this pull request as a manual merge; this run never \
2615 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2616 bump this change might warrant has to be filed by hand",
2617 );
2618 if state.status == RunStatus::Merged {
2621 crate::followup::after_merge(state, url).await;
2622 }
2623 state.save()?;
2624 Ok((before, state.status))
2625}
2626
2627pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2634 let raw: Vec<GhInline> =
2635 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2636 let mut out = Vec::new();
2637 for c in raw {
2638 push_if_outstanding(
2639 &mut out,
2640 ReviewComment {
2641 author: c.user.login,
2642 path: c.path,
2643 line: c.line,
2644 body: c.body,
2645 },
2646 );
2647 }
2648 Ok(out)
2649}
2650
2651fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2657 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2658 return;
2659 }
2660 if comment.path.is_none() && is_noise(&comment.body) {
2661 return;
2662 }
2663 out.push(comment);
2664}
2665
2666pub fn is_noise(body: &str) -> bool {
2684 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2685 return true;
2686 }
2687 let mut content = false;
2688 for line in strip_blocks(body).lines() {
2689 let line = unquote(line);
2690 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2691 continue;
2692 }
2693 content = true;
2694 break;
2695 }
2696 !content
2697}
2698
2699fn strip_blocks(body: &str) -> String {
2701 let mut out = String::with_capacity(body.len());
2702 let mut rest = body;
2703 loop {
2704 let open = ["<!--", "<details>"]
2705 .iter()
2706 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2707 .min_by_key(|(i, _)| *i);
2708 let Some((at, tag)) = open else {
2709 out.push_str(rest);
2710 return out;
2711 };
2712 out.push_str(&rest[..at]);
2713 let after = &rest[at + tag.len()..];
2714 let close = if tag == "<!--" { "-->" } else { "</details>" };
2715 match after.find(close) {
2716 Some(end) => rest = &after[end + close.len()..],
2717 None => return out,
2719 }
2720 }
2721}
2722
2723fn unquote(line: &str) -> &str {
2725 let mut s = line.trim();
2726 while let Some(rest) = s.strip_prefix('>') {
2727 s = rest.trim_start();
2728 }
2729 s.trim()
2730}
2731
2732fn is_checklist(line: &str) -> bool {
2734 let rest = line
2735 .strip_prefix("- ")
2736 .or_else(|| line.strip_prefix("* "))
2737 .unwrap_or("");
2738 let rest = rest.trim_start();
2739 matches!(
2740 rest.get(..3),
2741 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2742 )
2743}
2744
2745fn is_decoration(line: &str) -> bool {
2747 line.starts_with('#')
2748 || line.starts_with("[!")
2749 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2750}
2751
2752fn is_banner(line: &str) -> bool {
2759 let plain = drop_spans(line, "**", "**");
2760 let plain = if plain.contains("](") {
2761 drop_spans(&plain, "[", ")")
2762 } else {
2763 plain
2764 };
2765 !plain.chars().any(char::is_alphanumeric)
2766}
2767
2768fn drop_spans(s: &str, open: &str, close: &str) -> String {
2772 let mut out = String::with_capacity(s.len());
2773 let mut rest = s;
2774 while let Some(at) = rest.find(open) {
2775 out.push_str(&rest[..at]);
2776 let after = &rest[at + open.len()..];
2777 match after.find(close) {
2778 Some(end) => rest = &after[end + close.len()..],
2779 None => return out,
2780 }
2781 }
2782 out.push_str(rest);
2783 out
2784}
2785
2786fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2804 static LOCKS: std::sync::LazyLock<
2805 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2806 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2807 LOCKS
2808 .lock()
2809 .unwrap_or_else(std::sync::PoisonError::into_inner)
2810 .entry(repo.to_path_buf())
2811 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2812 .clone()
2813}
2814
2815fn repo_label(repo: &Path, pr_url: &str) -> String {
2818 let parts: Vec<&str> = pr_url.split('/').collect();
2819 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2820 && at >= 2
2821 && !parts[at - 1].is_empty()
2822 && !parts[at - 2].is_empty()
2823 {
2824 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2825 }
2826 repo.file_name()
2827 .map(|n| n.to_string_lossy().into_owned())
2828 .unwrap_or_default()
2829}
2830
2831fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2835 (pr.checks == Checks::Red).then(|| {
2836 format!(
2837 "Merged {repo_name} PR #{} with red checks: {} ({})",
2838 pr.number,
2839 if pr.failing.is_empty() {
2840 "(none named)".to_owned()
2841 } else {
2842 pr.failing.join(", ")
2843 },
2844 pr.url
2845 )
2846 })
2847}
2848
2849async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2853 let repo_name = repo_label(&state.repo, &pr.url);
2854 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2855 return;
2856 };
2857 if let Some(rec) = state.pr.as_mut() {
2858 rec.red_at_merge = pr.failing.clone();
2859 }
2860 state.event("land", summary.clone());
2861 crate::notices::raise_with(
2864 crate::notices::merged_red(&state.id, &summary),
2865 &state.config.notify,
2866 );
2867}
2868
2869pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2876 land_with(state, pr_url, &GhForge).await
2877}
2878
2879trait Forge {
2883 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2884 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2885 async fn poll(&self);
2886 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2889 #[allow(clippy::too_many_arguments)]
2890 async fn fix(
2891 &self,
2892 state: &mut RunState,
2893 pr: &PrState,
2894 round: usize,
2895 budget: usize,
2896 reason: &str,
2897 logs: &str,
2898 ) -> Result<Fixed>;
2899}
2900
2901struct GhForge;
2902
2903impl Forge for GhForge {
2904 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2905 observe(repo, pr_url).await
2906 }
2907 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2908 gh(repo, argv).await
2909 }
2910 async fn poll(&self) {
2911 tokio::time::sleep(POLL).await;
2912 }
2913 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2914 required_contexts_of(repo, base).await
2915 }
2916 async fn fix(
2917 &self,
2918 state: &mut RunState,
2919 pr: &PrState,
2920 round: usize,
2921 budget: usize,
2922 reason: &str,
2923 logs: &str,
2924 ) -> Result<Fixed> {
2925 fix_round(state, pr, round, budget, reason, logs).await
2926 }
2927}
2928
2929fn encode_path_segment(s: &str) -> String {
2931 let mut out = String::new();
2932 for b in s.bytes() {
2933 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2934 out.push(b as char);
2935 } else {
2936 let _ = write!(out, "%{b:02X}");
2937 }
2938 }
2939 out
2940}
2941
2942async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2947 let enc = encode_path_segment(base);
2948 let mut all = BTreeSet::new();
2949 let classic = gh(
2952 repo,
2953 &[
2954 "api".to_owned(),
2955 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2956 ],
2957 )
2958 .await
2959 .ok()?;
2960 if classic.0 {
2961 all.extend(parse_classic_required(&classic.1)?);
2962 } else if !classic.1.contains("404") {
2963 return None;
2964 }
2965 let rules = gh(
2966 repo,
2967 &[
2968 "api".to_owned(),
2969 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2970 ],
2971 )
2972 .await
2973 .ok()?;
2974 if !rules.0 {
2975 return None;
2976 }
2977 all.extend(parse_ruleset_required(&rules.1)?);
2978 Some(all)
2979}
2980
2981fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2984 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2985 let mut out = BTreeSet::new();
2986 for c in v.get("contexts")?.as_array()? {
2987 out.insert(c.as_str()?.to_owned());
2988 }
2989 for c in v
2990 .get("checks")
2991 .and_then(|c| c.as_array())
2992 .into_iter()
2993 .flatten()
2994 {
2995 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
2996 out.insert(name.to_owned());
2997 }
2998 }
2999 Some(out)
3000}
3001
3002fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3004 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3005 let mut out = BTreeSet::new();
3006 for rule in v.as_array()? {
3007 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3008 continue;
3009 }
3010 let checks = rule
3011 .pointer("/parameters/required_status_checks")?
3012 .as_array()?;
3013 for c in checks {
3014 out.insert(c.get("context")?.as_str()?.to_owned());
3015 }
3016 }
3017 Some(out)
3018}
3019
3020fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3028 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3029}
3030
3031fn bound_head<'a>(
3041 seen_head: &'a str,
3042 rollup_head: &str,
3043 awaiting: Option<&str>,
3044) -> Option<&'a str> {
3045 if seen_head.is_empty()
3046 || awaiting_new_head(awaiting, seen_head)
3047 || !rollup_head.eq_ignore_ascii_case(seen_head)
3048 {
3049 return None;
3050 }
3051 Some(seen_head)
3052}
3053
3054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3056enum Refused {
3057 Pending,
3059 Recheck,
3063 Final,
3066}
3067
3068fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3071 let Some(after) = after else {
3072 return Refused::Pending;
3074 };
3075 if after.pr.state != PrLifecycle::Open {
3076 return Refused::Final;
3077 }
3078 if !after.head.eq_ignore_ascii_case(observed_head) {
3081 return Refused::Pending;
3082 }
3083 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3085 return Refused::Pending;
3086 }
3087 let state = after.merge_state.to_ascii_uppercase();
3088 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3089 || state.is_empty()
3090 || state == "UNKNOWN"
3091 {
3092 return Refused::Pending;
3093 }
3094 if rechecked {
3095 Refused::Final
3096 } else {
3097 Refused::Recheck
3098 }
3099}
3100
3101async fn disarm<F: Forge>(
3106 forge: &F,
3107 state: &mut RunState,
3108 repo: &Path,
3109 number: u64,
3110) -> std::result::Result<(), String> {
3111 let argv = disable_automerge_argv(number);
3112 let out = {
3113 let merge_lock = repo_merge_lock(repo);
3114 let _merge_slot = merge_lock.lock().await;
3115 forge.merge(repo, &argv).await
3116 };
3117 match out {
3118 Ok((true, _)) => {
3119 state.land_armed_head = None;
3120 state.event("land", "auto-merge disabled");
3121 state.save().map_err(|e| format!("{e:#}"))?;
3122 Ok(())
3123 }
3124 Ok((false, msg)) => Err(msg),
3125 Err(e) => Err(format!("{e:#}")),
3126 }
3127}
3128
3129async fn stop_disarmed<F: Forge>(
3133 forge: &F,
3134 state: &mut RunState,
3135 repo: &Path,
3136 pr: &PrState,
3137 why: &str,
3138) -> Result<()> {
3139 if state.land_armed_head.is_none() {
3140 return stop(state, repo, pr, why).await;
3141 }
3142 match disarm(forge, state, repo, pr.number).await {
3143 Ok(()) => stop(state, repo, pr, why).await,
3144 Err(e) => {
3145 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3146 stop(state, repo, pr, &why).await
3147 }
3148 }
3149}
3150
3151async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3152 let repo = state.repo.clone();
3153 let budget = state.config.graph.land_rounds;
3154 let mut round = 0usize;
3155 let mut rebases = 0usize;
3158 let mut waited = Duration::ZERO;
3159 let mut shown: BTreeSet<String> = BTreeSet::new();
3164 let mut awaiting_head: Option<String> = None;
3168 let mut rechecked = false;
3170
3171 state.status = RunStatus::Landing;
3179 state.event("land", format!("watching {pr_url}"));
3180 state.save()?;
3181
3182 let mut resumed_armed = state.land_armed_head.is_some();
3186
3187 loop {
3188 let seen = forge.view(&repo, pr_url).await?;
3189 let mut pr = seen.pr.clone();
3190 pr.review_comments.retain(|c| !shown.contains(&c.body));
3191 state.pr = Some(crate::run::PrRecord {
3192 url: pr.url.clone(),
3193 number: pr.number,
3194 state: pr.state.as_str().to_owned(),
3195 checks: pr.checks.as_str().to_owned(),
3196 round,
3197 rounds: budget,
3198 red_at_merge: Vec::new(),
3199 });
3200 state.save()?;
3201
3202 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3203 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3208 let why = format!(
3209 "a previous pass may have armed auto-merge and it could not be disabled \
3210 on resume: {e}"
3211 );
3212 stop(state, &repo, &pr, &why).await?;
3213 return Ok(pr);
3214 }
3215 }
3216
3217 if pr.state == PrLifecycle::Open
3222 && !seen.head.is_empty()
3223 && state
3224 .land_armed_head
3225 .as_deref()
3226 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3227 {
3228 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3229 let why = format!(
3230 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3231 );
3232 stop(state, &repo, &pr, &why).await?;
3233 return Ok(pr);
3234 }
3235 }
3236
3237 if pr.state == PrLifecycle::Open {
3238 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3239 if waited >= WAIT_CEILING {
3240 let want = awaiting_head.as_deref().unwrap_or_default();
3241 let why = format!(
3242 "the pull request's checks were still not about one readable head after \
3243 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3244 someone may have pushed over it",
3245 WAIT_CEILING.as_secs() / 60,
3246 if want.is_empty() { "any" } else { want },
3247 if seen.head.is_empty() {
3248 "nothing readable"
3249 } else {
3250 &seen.head
3251 },
3252 if seen.rollup_head.is_empty() {
3253 "nothing readable"
3254 } else {
3255 &seen.rollup_head
3256 },
3257 );
3258 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3259 return Ok(pr);
3260 }
3261 waited += POLL;
3262 forge.poll().await;
3263 continue;
3264 }
3265 if awaiting_head.take().is_some() {
3268 waited = Duration::ZERO;
3271 }
3272 }
3273
3274 let step = decide(&pr, round, budget, waited);
3275 let armed_here = state
3280 .land_armed_head
3281 .as_deref()
3282 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3283 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3284 if waited >= WAIT_CEILING {
3285 let required = if seen.base.is_empty() {
3286 None
3287 } else {
3288 forge.required_contexts(&repo, &seen.base).await
3289 };
3290 let why = format!(
3291 "auto-merge was armed on {} but the pull request did not merge within {} \
3292 minutes ({})",
3293 seen.head,
3294 WAIT_CEILING.as_secs() / 60,
3295 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3296 );
3297 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3298 return Ok(pr);
3299 }
3300 waited += POLL;
3301 forge.poll().await;
3302 continue;
3303 }
3304 if armed_here && !matches!(step, Step::Done { .. }) {
3305 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3308 let why = format!("auto-merge could not be disabled: {e}");
3309 stop(state, &repo, &pr, &why).await?;
3310 return Ok(pr);
3311 }
3312 }
3313 match step {
3314 Step::Wait => {
3315 if waited >= WAIT_CEILING {
3316 let why = format!(
3317 "checks were still running after {} minutes",
3318 WAIT_CEILING.as_secs() / 60
3319 );
3320 stop(state, &repo, &pr, &why).await?;
3321 return Ok(pr);
3322 }
3323 waited += POLL;
3324 forge.poll().await;
3325 }
3326 Step::Done { merged } => {
3327 if let Some(armed) = state.land_armed_head.take() {
3332 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3333 let msg = format!(
3334 "{} merged on {} but the owner approved {armed}; review what landed",
3335 pr.url, seen.head
3336 );
3337 tracing::warn!("{msg}");
3338 state.event("land", msg);
3339 crate::notices::raise_with(
3343 crate::notices::Notice::warn(
3344 &format!("merged-unapproved-head:{}", state.id),
3345 "A pull request merged on a commit the owner did not approve; \
3346 review what landed",
3347 )
3348 .link(crate::notices::Link::Run {
3349 id: state.id.clone(),
3350 }),
3351 &state.config.notify,
3352 );
3353 }
3354 }
3355 state.status = if merged {
3356 RunStatus::Merged
3357 } else {
3358 RunStatus::Ready
3359 };
3360 let detail = if merged {
3361 format!("{} was merged", pr.url)
3362 } else {
3363 format!("{} was closed without merging", pr.url)
3364 };
3365 state.merge = Some(MergeOutcome {
3366 mode: MergeMode::Pr,
3367 ok: merged,
3368 detail: detail.clone(),
3369 empty: false,
3370 });
3371 state.event("land", detail);
3372 state.save()?;
3373 write_pr_state_through(state, pr.state);
3374 return Ok(pr);
3375 }
3376 Step::Merge => {
3377 let subject = merge_subject(
3378 crate::graph::landing_title(state, &seen.title),
3379 &crate::graph::landing_subject_source(state),
3380 );
3381 let contested = contested_to_ask(state);
3388 if state.config.graph.land_approval || contested.is_some() {
3389 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3390 .await?
3391 {
3392 ApprovalGate::Approved => {}
3393 ApprovalGate::Held => {
3394 stop(
3395 state,
3396 &repo,
3397 &pr,
3398 "the owner did not approve the merge (held or unanswered)",
3399 )
3400 .await?;
3401 return Ok(pr);
3402 }
3403 ApprovalGate::Pending => {
3411 state.parked = true;
3412 state.event(
3413 "land",
3414 "parked awaiting merge approval - resumes once answered",
3415 );
3416 state.save()?;
3417 return Ok(pr);
3418 }
3419 }
3420 }
3421 let observed_head = seen.head.clone();
3432 {
3435 let fresh = forge.view(&repo, pr_url).await.ok();
3436 if !direct_merge_is_safe(
3437 fresh.as_ref(),
3438 &observed_head,
3439 &shown,
3440 round,
3441 budget,
3442 waited,
3443 ) {
3444 if waited >= WAIT_CEILING {
3445 let why = "the pull request did not settle on the approved head \
3446 before it could be merged";
3447 stop(state, &repo, &pr, why).await?;
3448 return Ok(pr);
3449 }
3450 state.event(
3451 "land",
3452 "the pull request changed before merging; looking again",
3453 );
3454 waited += POLL;
3455 forge.poll().await;
3456 continue;
3457 }
3458 }
3459 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3460 let out = {
3461 let merge_lock = repo_merge_lock(&repo);
3462 let _merge_slot = merge_lock.lock().await;
3463 forge.merge(&repo, &argv).await?
3464 };
3465 if out.0 {
3466 let confirmed = forge
3472 .view(&repo, pr_url)
3473 .await
3474 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3475 if !confirmed {
3476 if waited >= WAIT_CEILING {
3477 let why = "the merge request succeeded but the pull request \
3478 could not be confirmed merged after waiting";
3479 stop(state, &repo, &pr, why).await?;
3480 return Ok(pr);
3481 }
3482 state.event(
3483 "land",
3484 "merge accepted but the pull request is not confirmed merged yet; waiting",
3485 );
3486 state.save()?;
3487 waited += POLL;
3488 forge.poll().await;
3489 continue;
3490 }
3491 pr.state = PrLifecycle::Merged;
3492 state.status = RunStatus::Merged;
3493 state.merge = Some(MergeOutcome {
3494 mode: MergeMode::Pr,
3495 ok: true,
3496 detail: format!("gh {}", argv.join(" ")),
3497 empty: false,
3498 });
3499 if let Some(pr_record) = state.pr.as_mut() {
3504 pr_record.state = pr.state.as_str().to_owned();
3505 }
3506 state.event("land", format!("merged {} as `{subject}`", pr.url));
3507 announce_red_merge(state, &pr).await;
3508 state.save()?;
3509 write_pr_state_through(state, pr.state);
3510 return Ok(pr);
3511 }
3512 let after_seen = forge.view(&repo, pr_url).await.ok();
3513 let after = after_seen.as_ref().map(|s| s.pr.state);
3514 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3515 pr.state = PrLifecycle::Merged;
3516 state.status = RunStatus::Merged;
3517 state.merge = Some(outcome);
3518 if let Some(pr_record) = state.pr.as_mut() {
3519 pr_record.state = pr.state.as_str().to_owned();
3520 }
3521 state.event("land", format!("merged {} as `{subject}`", pr.url));
3522 announce_red_merge(state, &pr).await;
3523 state.save()?;
3524 write_pr_state_through(state, pr.state);
3525 return Ok(pr);
3526 }
3527 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3528 match verdict {
3529 Refused::Final => {
3530 let merge_state = after_seen
3531 .as_ref()
3532 .map(|s| s.merge_state.as_str())
3533 .filter(|m| !m.is_empty())
3534 .unwrap_or("unknown");
3535 let why = format!(
3539 "the merge was refused: {} (merge state: {merge_state})",
3540 out.1
3541 );
3542 stop(state, &repo, &pr, &why).await?;
3543 return Ok(pr);
3544 }
3545 verdict => {
3546 if waited >= WAIT_CEILING {
3551 let why = format!(
3552 "the merge was still refused after {} minutes: {}",
3553 WAIT_CEILING.as_secs() / 60,
3554 out.1
3555 );
3556 stop(state, &repo, &pr, &why).await?;
3557 return Ok(pr);
3558 }
3559 if verdict == Refused::Recheck {
3560 rechecked = true;
3561 }
3562 state.event(
3563 "land",
3564 "merge refused while the branch policy is not satisfied yet; waiting",
3565 );
3566 state.save()?;
3567 waited += POLL;
3568 forge.poll().await;
3569 }
3570 }
3571 }
3572 Step::Rebase => {
3573 if rebases >= budget {
3579 let why = format!(
3580 "the base moved under this branch {budget} time(s) and it still does \
3581 not merge; rebasing again would only race it"
3582 );
3583 stop(state, &repo, &pr, &why).await?;
3584 return Ok(pr);
3585 }
3586 rebases += 1;
3587 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3588 stop(
3589 state,
3590 &repo,
3591 &pr,
3592 "the pull request conflicts and this run has no winning branch to rebase",
3593 )
3594 .await?;
3595 return Ok(pr);
3596 };
3597 let base = state.base_branch.clone();
3598 state.event(
3599 "land",
3600 format!("{} no longer merges; rebasing onto {base}", pr.url),
3601 );
3602 state.save()?;
3603
3604 git::fetch(&repo, "origin", &base).await.ok();
3608 let scratch = state.dir().join("rebase");
3609 let onto = format!("origin/{base}");
3610 let rebased =
3611 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3612 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3613 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3614 Err(e) => Err(e),
3615 };
3616 match rebased {
3617 Ok(None) => {
3618 let pushed = {
3619 let merge_lock = repo_merge_lock(&repo);
3620 let _merge_slot = merge_lock.lock().await;
3621 git::push_rewritten(&repo, "origin", &branch).await?
3622 };
3623 if !pushed.ok() {
3624 let why = format!(
3625 "rebased {branch} but could not push it: {}",
3626 pushed.stderr.trim()
3627 );
3628 stop(state, &repo, &pr, &why).await?;
3629 return Ok(pr);
3630 }
3631 let head =
3634 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3635 Ok(head) => head,
3636 Err(e) => {
3637 let why = format!(
3638 "rebased and pushed {branch} but could not read the pushed \
3639 commit: {e:#}"
3640 );
3641 stop(state, &repo, &pr, &why).await?;
3642 return Ok(pr);
3643 }
3644 };
3645 crate::graph::refresh_reviewed_commits(state, &branch).await;
3646 awaiting_head = Some(head);
3647 rechecked = false;
3648 state.event("land", format!("rebased {branch} onto {base}"));
3649 state.save()?;
3650 waited = Duration::ZERO;
3653 tokio::time::sleep(POLL).await;
3654 }
3655 Ok(Some(conflict)) => {
3658 let why = format!(
3659 "{} conflicts with {base} and the rebase did not apply: {}",
3660 pr.url,
3661 conflict.chars().take(600).collect::<String>()
3662 );
3663 stop(state, &repo, &pr, &why).await?;
3664 return Ok(pr);
3665 }
3666 Err(e) => {
3667 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3668 stop(state, &repo, &pr, &why).await?;
3669 return Ok(pr);
3670 }
3671 }
3672 }
3673 Step::GiveUp { reason } => {
3674 stop(state, &repo, &pr, &reason).await?;
3675 return Ok(pr);
3676 }
3677 Step::Fix { reason } => {
3678 round += 1;
3679 waited = Duration::ZERO;
3680 for c in &pr.review_comments {
3681 shown.insert(c.body.clone());
3682 }
3683 state.event("land", format!("round {round}: {reason}"));
3684 state.save()?;
3685
3686 let logs = failing_logs(&repo, &seen.failing_urls).await;
3687 let was_red = pr.checks == Checks::Red;
3688 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3689 Fixed::Committed { head } => {
3690 awaiting_head = Some(head);
3693 rechecked = false;
3694 waited = Duration::ZERO;
3695 forge.poll().await;
3696 }
3697 Fixed::Declined if was_red => {
3698 let why = format!(
3699 "the fixer produced no commit while {} check(s) were failing \
3700 ({}); stopping instead of looping on an unchanged tree",
3701 pr.failing.len(),
3702 pr.failing.join(", ")
3703 );
3704 stop(state, &repo, &pr, &why).await?;
3705 return Ok(pr);
3706 }
3707 Fixed::Declined => state.event(
3712 "land",
3713 format!("round {round}: fixer declined the comments, nothing committed"),
3714 ),
3715 Fixed::Failed(why) => {
3716 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3717 return Ok(pr);
3718 }
3719 }
3720 state.save()?;
3721 }
3722 }
3723 }
3724}
3725
3726#[derive(Clone)]
3730struct Seen {
3731 pr: PrState,
3732 title: String,
3733 failing_urls: Vec<(String, String)>,
3734 head: String,
3736 rollup_head: String,
3739 merge_state: String,
3742 contexts: Vec<CheckInfo>,
3744 base: String,
3746}
3747
3748#[derive(Clone)]
3750struct CheckInfo {
3751 label: String,
3752 verdict: Verdict,
3753 required: Option<bool>,
3754}
3755
3756async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3760 let view = gh(
3761 repo,
3762 &[
3763 "pr".to_owned(),
3764 "view".to_owned(),
3765 pr_url.to_owned(),
3766 "--json".to_owned(),
3767 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3768 .to_owned(),
3769 ],
3770 )
3771 .await?;
3772 if !view.0 {
3773 bail!("gh pr view {pr_url}: {}", view.1);
3774 }
3775 let number = parse_pr(&view.1)?.number;
3776 let node = last_commit_node(repo, number).await;
3777 let mut seen = seen_from(&view.1, node.as_deref())?;
3778
3779 let inline = gh(
3780 repo,
3781 &[
3782 "api".to_owned(),
3783 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3784 ],
3785 )
3786 .await?;
3787 if inline.0 {
3788 match parse_inline_comments(&inline.1) {
3789 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3790 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3793 }
3794 } else {
3795 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3796 }
3797 Ok(seen)
3798}
3799
3800fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3811 let mut pr = parse_pr(view_json)?;
3812 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3813
3814 let mut rollup_head = String::new();
3815 let mut failing_urls = Vec::new();
3816 let mut contexts = Vec::new();
3817 let mut checks = Checks::Unknown;
3818 let mut failing = Vec::new();
3819 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3820 (checks, failing) = rollup_verdict(&rollup);
3821 failing_urls = rollup
3822 .iter()
3823 .filter(|c| c.verdict() == Verdict::Fail)
3824 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3825 .collect();
3826 contexts = rollup
3827 .iter()
3828 .map(|c| CheckInfo {
3829 label: c.label(),
3830 verdict: c.verdict(),
3831 required: c.is_required,
3832 })
3833 .collect();
3834 rollup_head = oid;
3835 }
3836 pr.checks = checks;
3837 pr.failing = failing;
3838
3839 Ok(Seen {
3840 pr,
3841 title: raw.title,
3842 failing_urls,
3843 head: raw.head_ref_oid,
3844 rollup_head,
3845 merge_state: raw.merge_state_status,
3846 contexts,
3847 base: raw.base_ref_name,
3848 })
3849}
3850
3851fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3854 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3855 if v.get("errors").is_some_and(|e| !e.is_null()) {
3856 return None;
3857 }
3858 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3859 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3860 let contexts = commit.pointer("/statusCheckRollup/contexts");
3861 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3862 return Some((oid.to_owned(), Vec::new()));
3864 };
3865 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3866 return None;
3867 }
3868 let nodes = contexts.get("nodes")?.as_array()?;
3869 let rollup = nodes
3870 .iter()
3871 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3872 .collect::<Result<Vec<_>, _>>()
3873 .ok()?;
3874 Some((oid.to_owned(), rollup))
3875}
3876
3877async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3880 let out = gh(
3881 repo,
3882 &[
3883 "api".to_owned(),
3884 "graphql".to_owned(),
3885 "-F".to_owned(),
3886 "owner={owner}".to_owned(),
3887 "-F".to_owned(),
3888 "repo={repo}".to_owned(),
3889 "-F".to_owned(),
3890 format!("number={number}"),
3891 "-f".to_owned(),
3892 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3893 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3894 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3895 ... on CheckRun{name status conclusion detailsUrl \
3896 isRequired(pullRequestNumber:$number)} \
3897 ... on StatusContext{context state targetUrl \
3898 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3899 .to_owned(),
3900 ],
3901 )
3902 .await
3903 .ok()?;
3904 out.0.then_some(out.1)
3905}
3906
3907#[doc(hidden)]
3909#[derive(Debug, PartialEq)]
3910pub enum Fixed {
3911 Committed {
3913 head: String,
3915 },
3916 Declined,
3918 Failed(String),
3920}
3921
3922#[doc(hidden)]
3928pub async fn fix_round(
3929 state: &mut RunState,
3930 pr: &PrState,
3931 round: usize,
3932 budget: usize,
3933 reason: &str,
3934 logs: &str,
3935) -> Result<Fixed> {
3936 let winner = state
3937 .winner()
3938 .cloned()
3939 .context("landing needs a winning candidate; none is recorded on this run")?;
3940 let roles = state
3941 .config
3942 .resolve_roles()
3943 .context("resolve the roster for the fix round")?;
3944 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
3948 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3949 _ => (
3950 state
3951 .config
3952 .agent(&winner.agent)
3953 .cloned()
3954 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
3955 format!("impl-{}", winner.label),
3956 ),
3957 };
3958
3959 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3960 let mut seat = seat_of(state, &seat_key, &spec.id);
3961 let artifacts = agent::artifacts_dir(&state.dir());
3962 let prompt = if state.config.cache_dir().is_some() {
3963 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3964 } else {
3965 prompt
3966 };
3967 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3971 let out = agent::invoke(
3972 &spec,
3973 &mut seat,
3974 &Invocation {
3975 cwd: &winner.worktree,
3976 prompt: &prompt,
3977 timeout: Duration::from_secs(state.config.graph.timeout_fix),
3978 allow_write: true,
3979 sessions: state.config.graph.sessions,
3980 artifacts: &artifacts,
3981 stem: &format!("land-{round}"),
3982 run: &state.id,
3983 node: "land",
3984 cache_dir: state.config.cache_dir().as_deref(),
3985 attachments: &[],
3986 writable: &[],
3987 },
3988 )
3989 .await;
3990 state.seats.insert(seat.key.clone(), seat);
3991
3992 match out {
3993 Ok(o) if o.quota_exhausted() => {
3994 return Ok(Fixed::Failed(
3995 "rate limited (quota); the fixer could not run".to_owned(),
3996 ));
3997 }
3998 Ok(o) if !o.usable() => {
3999 return Ok(Fixed::Failed(format!(
4000 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4001 o.exit_code, o.timed_out
4002 )));
4003 }
4004 Ok(_) => {}
4005 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4006 }
4007
4008 if let Ok(r) = git::rescue_commit(
4011 &winner.worktree,
4012 &format!("magi: land round {round} fixes (uncommitted work)"),
4013 )
4014 .await
4015 {
4016 state.note_withheld("land", &r.withheld);
4017 }
4018 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4019 if after == before {
4020 return Ok(Fixed::Declined);
4021 }
4022
4023 let remote = state.config.merge.remote.clone();
4024 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4025 if !push.ok() {
4026 return Ok(Fixed::Failed(format!(
4027 "pushing {} to {remote} failed: {}",
4028 winner.branch, push.stderr
4029 )));
4030 }
4031 state.event(
4032 "land",
4033 format!("round {round}: pushed a fix to {}", winner.branch),
4034 );
4035 Ok(Fixed::Committed { head: after })
4036}
4037
4038pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4040 if let Some(existing) = state.seats.get(key)
4041 && existing.agent == agent
4042 {
4043 return existing.clone();
4044 }
4045 let fresh = SeatState::new(key, agent, state.seed);
4046 state.seats.insert(key.to_owned(), fresh.clone());
4047 fresh
4048}
4049
4050fn fix_prompt(
4052 state: &RunState,
4053 pr: &PrState,
4054 round: usize,
4055 budget: usize,
4056 reason: &str,
4057 logs: &str,
4058) -> String {
4059 let mut s = format!(
4060 "Your patch is open as a pull request and it is not landing. Land round \
4061 {round} of {budget}.\n\n\
4062 Pull request: {}\n\n\
4063 What is holding it: {reason}\n\n\
4064 # The task\n\n{}\n",
4065 pr.url, state.instruction
4066 );
4067
4068 if pr.failing.is_empty() {
4069 s.push_str("\n# Failing checks\n\n(none)\n");
4070 } else {
4071 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4072 if logs.trim().is_empty() {
4073 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4074 } else {
4075 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4076 }
4077 }
4078
4079 if pr.review_comments.is_empty() {
4080 s.push_str("\n# Review comments\n\n(none)\n");
4081 } else {
4082 s.push_str("\n# Review comments\n");
4083 for c in &pr.review_comments {
4084 let where_ = match (&c.path, c.line) {
4085 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4086 (Some(p), None) => format!(" ({p})"),
4087 _ => String::new(),
4088 };
4089 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4090 }
4091 }
4092
4093 s.push_str(
4094 "\n# Rules\n\n\
4095 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4096 failing test; do not silence a lint with an allow attribute; do not \
4097 stretch a timeout to hide a race. If the check is right, the code is \
4098 wrong.\n\
4099 2. Change nothing the checks and the comments did not raise. A \
4100 drive-by refactor turns a one-line fix into a pull request that \
4101 needs reviewing again.\n\
4102 3. If a comment is wrong, say so with a checkable argument and change \
4103 nothing for it. A declined comment with a reason is a correct \
4104 outcome; a change made to appease a reviewer is not.\n\
4105 4. Commit in this worktree. magi pushes to the pull request's branch \
4106 for you; do not push, merge, or close anything yourself.\n\
4107 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4108 # Output\n\n\
4109 Say what you changed and why, and what you declined and why.",
4110 );
4111
4112 let language = &state.config.graph.language;
4113 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4114 let _ = write!(s, "\n\nWrite all prose in {language}.");
4115 }
4116 s.push_str(&crate::prompt::github_english(language));
4118 if let Some(overlay) = state.config.prompts.overlay("fix") {
4119 let _ = write!(s, "\n\n{overlay}");
4120 }
4121 s
4122}
4123
4124async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4127 let mut out = String::new();
4128 for (name, url) in failing.iter().take(MAX_LOGS) {
4129 let args = match (job_of(url), run_of(url)) {
4130 (Some(job), _) => vec![
4131 "run".to_owned(),
4132 "view".to_owned(),
4133 "--log-failed".to_owned(),
4134 "--job".to_owned(),
4135 job,
4136 ],
4137 (None, Some(run)) => vec![
4138 "run".to_owned(),
4139 "view".to_owned(),
4140 run,
4141 "--log-failed".to_owned(),
4142 ],
4143 (None, None) => continue,
4145 };
4146 let (ok, body) = match gh(repo, &args).await {
4147 Ok(v) => v,
4148 Err(e) => (false, format!("{e:#}")),
4149 };
4150 if !ok && body.trim().is_empty() {
4151 continue;
4152 }
4153 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4154 }
4155 out
4156}
4157
4158fn job_of(details_url: &str) -> Option<String> {
4161 let after = details_url.split("/job/").nth(1)?;
4162 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4163 (!id.is_empty()).then_some(id)
4164}
4165
4166pub(crate) fn run_of(details_url: &str) -> Option<String> {
4168 let after = details_url.split("/actions/runs/").nth(1)?;
4169 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4170 (!id.is_empty()).then_some(id)
4171}
4172
4173fn stop_comment(run_id: &str, why: &str) -> String {
4177 format!(
4178 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4179 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4180 )
4181}
4182
4183async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4188 let body = stop_comment(&state.id, why);
4189 let posted = gh(
4190 repo,
4191 &[
4192 "pr".to_owned(),
4193 "comment".to_owned(),
4194 pr.number.to_string(),
4195 "--body".to_owned(),
4196 body,
4197 ],
4198 )
4199 .await;
4200 match posted {
4201 Ok((true, _)) => {}
4202 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4203 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4204 }
4205 state.status = RunStatus::Blocked;
4206 state.merge = Some(MergeOutcome {
4207 mode: MergeMode::Pr,
4208 ok: false,
4209 detail: why.to_owned(),
4210 empty: false,
4211 });
4212 state.event("land", format!("stopped: {why}"));
4213 state.save()?;
4214 Ok(())
4215}
4216
4217pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4233 let out = tokio::process::Command::new("gh")
4234 .args(args)
4235 .current_dir(cwd)
4236 .env_remove("GH_REPO")
4237 .quiet()
4238 .stdin(std::process::Stdio::null())
4239 .output()
4240 .await
4241 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4242 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4243 let err = String::from_utf8_lossy(&out.stderr);
4244 if body.trim().is_empty() {
4245 body = err.into_owned();
4246 } else if !err.trim().is_empty() {
4247 body.push_str(&err);
4248 }
4249 Ok((out.status.success(), body.trim().to_owned()))
4250}
4251
4252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4254pub(crate) enum Verdict {
4255 Pass,
4256 Fail,
4257 Pending,
4258 Unknown,
4259}
4260
4261#[derive(Debug, Deserialize)]
4262#[serde(rename_all = "camelCase")]
4263struct GhPr {
4264 #[serde(default)]
4265 url: String,
4266 #[serde(default)]
4267 number: u64,
4268 #[serde(default)]
4269 state: String,
4270 #[serde(default)]
4271 title: String,
4272 #[serde(default)]
4273 status_check_rollup: Vec<GhCheck>,
4274 #[serde(default)]
4281 merge_state_status: String,
4282 #[serde(default)]
4286 head_ref_oid: String,
4287 #[serde(default)]
4288 base_ref_name: String,
4289 #[serde(default)]
4290 reviews: Vec<GhReview>,
4291 #[serde(default)]
4292 comments: Vec<GhComment>,
4293}
4294
4295#[derive(Debug, Deserialize)]
4300#[serde(rename_all = "camelCase")]
4301struct GhCheck {
4302 #[serde(default)]
4303 name: Option<String>,
4304 #[serde(default)]
4305 context: Option<String>,
4306 #[serde(default)]
4307 status: Option<String>,
4308 #[serde(default)]
4309 conclusion: Option<String>,
4310 #[serde(default)]
4311 state: Option<String>,
4312 #[serde(default)]
4313 details_url: Option<String>,
4314 #[serde(default)]
4315 target_url: Option<String>,
4316 #[serde(default)]
4319 is_required: Option<bool>,
4320}
4321
4322impl GhCheck {
4323 fn label(&self) -> String {
4325 self.name
4326 .clone()
4327 .or_else(|| self.context.clone())
4328 .unwrap_or_else(|| "(unnamed check)".to_owned())
4329 }
4330
4331 fn url(&self) -> Option<&str> {
4333 self.details_url
4334 .as_deref()
4335 .or(self.target_url.as_deref())
4336 .filter(|u| !u.is_empty())
4337 }
4338
4339 fn verdict(&self) -> Verdict {
4347 if let Some(status) = self.status.as_deref() {
4348 if !status.eq_ignore_ascii_case("COMPLETED") {
4349 return Verdict::Pending;
4350 }
4351 }
4352 let outcome = self
4353 .conclusion
4354 .as_deref()
4355 .or(self.state.as_deref())
4356 .unwrap_or("");
4357 match outcome.to_ascii_uppercase().as_str() {
4358 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4359 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4360 | "ACTION_REQUIRED" => Verdict::Fail,
4361 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4362 Verdict::Pending
4363 }
4364 _ => Verdict::Unknown,
4365 }
4366 }
4367}
4368
4369#[derive(Debug, Deserialize)]
4370struct GhAuthor {
4371 #[serde(default)]
4372 login: String,
4373}
4374
4375#[derive(Debug, Deserialize)]
4376struct GhReview {
4377 #[serde(default)]
4378 author: GhAuthor,
4379 #[serde(default)]
4380 body: String,
4381}
4382
4383#[derive(Debug, Deserialize)]
4384struct GhComment {
4385 #[serde(default)]
4386 author: GhAuthor,
4387 #[serde(default)]
4388 body: String,
4389}
4390
4391#[derive(Debug, Deserialize)]
4392struct GhUser {
4393 #[serde(default)]
4394 login: String,
4395}
4396
4397#[derive(Debug, Deserialize)]
4398struct GhInline {
4399 #[serde(default)]
4400 user: GhUser,
4401 #[serde(default)]
4402 path: Option<String>,
4403 #[serde(default)]
4404 line: Option<u64>,
4405 #[serde(default)]
4406 body: String,
4407}
4408
4409impl Default for GhAuthor {
4410 fn default() -> Self {
4411 Self {
4412 login: "(unknown)".to_owned(),
4413 }
4414 }
4415}
4416
4417impl Default for GhUser {
4418 fn default() -> Self {
4419 Self {
4420 login: "(unknown)".to_owned(),
4421 }
4422 }
4423}
4424
4425#[cfg(test)]
4426mod tests {
4427 use super::*;
4428 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4429
4430 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4431 format!(
4432 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4433 )
4434 }
4435
4436 #[test]
4437 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4438 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4439 assert_eq!(
4440 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4441 Ok(())
4442 );
4443 for (json, why) in [
4444 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4445 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4446 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4447 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4448 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4449 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4450 ] {
4451 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4452 .unwrap_err()
4453 .why;
4454 assert!(err.contains(why), "{json}: {err}");
4455 }
4456 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4458 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4459 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4460 assert!(
4461 !closable(
4462 &head_json("x", "main", "OPEN", false),
4463 "magi/27b2/A",
4464 "main",
4465 &[]
4466 )
4467 .unwrap_err()
4468 .retry
4469 );
4470 assert!(is_forge_url("https://github.com/o/r.git"));
4471 assert!(is_forge_url("git@github.com:o/r.git"));
4472 assert!(!is_forge_url("/tmp/origin.git"));
4473 assert!(!is_forge_url("C:\\work\\origin.git"));
4474 assert!(!is_forge_url("file:///tmp/origin.git"));
4475 assert!(forge_unavailable(
4476 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4477 ));
4478 assert!(!forge_unavailable(
4479 "gh pr list failed: error connecting to api.github.com"
4480 ));
4481 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4482 assert!(
4484 closable(
4485 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4486 "b",
4487 "main",
4488 &["aaa".to_owned()]
4489 )
4490 .is_err()
4491 );
4492 }
4493
4494 #[test]
4495 fn the_close_comment_names_the_commit_on_the_base() {
4496 let e = crate::already::Evidence {
4497 proof: crate::already::Proof::PatchId,
4498 tip: "1234567890".to_owned(),
4499 commits: vec!["0e368de0000".to_owned()],
4500 };
4501 let c = superseded_comment("main", &e);
4502 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4503 }
4504
4505 const GREEN_OPEN: &str = r####"{
4507 "url": "https://github.com/yukimemi/magi/pull/10",
4508 "number": 10,
4509 "state": "OPEN",
4510 "mergeStateStatus": "CLEAN",
4511 "statusCheckRollup": [
4512 {
4513 "__typename": "CheckRun",
4514 "conclusion": "SKIPPED",
4515 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4516 "name": "review",
4517 "status": "COMPLETED",
4518 "workflowName": "claude-review"
4519 },
4520 {
4521 "__typename": "CheckRun",
4522 "conclusion": "SUCCESS",
4523 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4524 "name": "check (ubuntu-latest)",
4525 "status": "COMPLETED",
4526 "workflowName": "CI"
4527 },
4528 {
4529 "__typename": "CheckRun",
4530 "conclusion": "SUCCESS",
4531 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4532 "name": "rustfmt",
4533 "status": "COMPLETED",
4534 "workflowName": "CI"
4535 },
4536 {
4537 "__typename": "StatusContext",
4538 "context": "CodeRabbit",
4539 "state": "SUCCESS",
4540 "targetUrl": ""
4541 }
4542 ],
4543 "reviews": [],
4544 "comments": [
4545 {
4546 "author": {
4547 "login": "coderabbitai"
4548 },
4549 "authorAssociation": "NONE",
4550 "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"
4551 }
4552 ]
4553}"####;
4554
4555 const RED_OPEN: &str = r####"{
4557 "url": "https://github.com/yukimemi/magi/pull/9",
4558 "number": 9,
4559 "state": "OPEN",
4560 "mergeStateStatus": "UNSTABLE",
4561 "statusCheckRollup": [
4562 {
4563 "__typename": "CheckRun",
4564 "conclusion": "SUCCESS",
4565 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4566 "name": "check (ubuntu-latest)",
4567 "status": "COMPLETED",
4568 "workflowName": "CI"
4569 },
4570 {
4571 "__typename": "CheckRun",
4572 "conclusion": "SUCCESS",
4573 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4574 "name": "rustfmt",
4575 "status": "COMPLETED",
4576 "workflowName": "CI"
4577 },
4578 {
4579 "__typename": "CheckRun",
4580 "conclusion": "FAILURE",
4581 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4582 "name": "editorconfig",
4583 "status": "COMPLETED",
4584 "workflowName": "CI"
4585 },
4586 {
4587 "__typename": "StatusContext",
4588 "context": "CodeRabbit",
4589 "state": "SUCCESS",
4590 "targetUrl": ""
4591 }
4592 ],
4593 "reviews": [],
4594 "comments": [
4595 {
4596 "author": {
4597 "login": "coderabbitai"
4598 },
4599 "authorAssociation": "NONE",
4600 "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"
4601 }
4602 ]
4603}"####;
4604
4605 const PENDING_OPEN: &str = r####"{
4607 "url": "https://github.com/yukimemi/magi/pull/9",
4608 "number": 9,
4609 "state": "OPEN",
4610 "statusCheckRollup": [
4611 {
4612 "__typename": "CheckRun",
4613 "conclusion": "SUCCESS",
4614 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4615 "name": "check (ubuntu-latest)",
4616 "status": "COMPLETED",
4617 "workflowName": "CI"
4618 },
4619 {
4620 "__typename": "CheckRun",
4621 "conclusion": "SUCCESS",
4622 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4623 "name": "rustfmt",
4624 "status": "COMPLETED",
4625 "workflowName": "CI"
4626 },
4627 {
4628 "__typename": "CheckRun",
4629 "conclusion": null,
4630 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4631 "name": "editorconfig",
4632 "status": "IN_PROGRESS",
4633 "workflowName": "CI"
4634 },
4635 {
4636 "__typename": "StatusContext",
4637 "context": "CodeRabbit",
4638 "state": "SUCCESS",
4639 "targetUrl": ""
4640 }
4641 ],
4642 "reviews": [],
4643 "comments": []
4644}"####;
4645
4646 const MERGED: &str = r####"{
4648 "url": "https://github.com/yukimemi/magi/pull/16",
4649 "number": 16,
4650 "state": "MERGED",
4651 "statusCheckRollup": [
4652 {
4653 "__typename": "CheckRun",
4654 "conclusion": "SUCCESS",
4655 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4656 "name": "check (ubuntu-latest)",
4657 "status": "COMPLETED",
4658 "workflowName": "CI"
4659 },
4660 {
4661 "__typename": "CheckRun",
4662 "conclusion": "SUCCESS",
4663 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4664 "name": "review",
4665 "status": "COMPLETED",
4666 "workflowName": "claude-review"
4667 }
4668 ],
4669 "reviews": [],
4670 "comments": []
4671}"####;
4672
4673 const REVIEWED_OPEN: &str = r####"{
4675 "url": "https://github.com/yukimemi/magi/pull/12",
4676 "number": 12,
4677 "state": "OPEN",
4678 "statusCheckRollup": [
4679 {
4680 "__typename": "CheckRun",
4681 "conclusion": "SUCCESS",
4682 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4683 "name": "check (ubuntu-latest)",
4684 "status": "COMPLETED",
4685 "workflowName": "CI"
4686 },
4687 {
4688 "__typename": "CheckRun",
4689 "conclusion": "SUCCESS",
4690 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4691 "name": "review",
4692 "status": "COMPLETED",
4693 "workflowName": "claude-review"
4694 }
4695 ],
4696 "reviews": [
4697 {
4698 "author": {
4699 "login": "claude"
4700 },
4701 "state": "COMMENTED",
4702 "body": ""
4703 }
4704 ],
4705 "comments": [
4706 {
4707 "author": {
4708 "login": "coderabbitai"
4709 },
4710 "authorAssociation": "NONE",
4711 "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"
4712 },
4713 {
4714 "author": {
4715 "login": "claude"
4716 },
4717 "authorAssociation": "NONE",
4718 "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"
4719 }
4720 ]
4721}"####;
4722
4723 const INLINE: &str = r####"[
4725 {
4726 "user": {
4727 "login": "claude[bot]"
4728 },
4729 "path": "src/graph.rs",
4730 "line": 231,
4731 "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"
4732 }
4733]"####;
4734
4735 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4737<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4738
4739> [!IMPORTANT]
4740> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4741>
4742> This repository does not receive automatic reviews because it has fewer than 10 stars.
4743>
4744> <details>
4745> <summary>⚙️ Run configuration</summary>
4746>
4747> **Configuration used**: defaults
4748>
4749> **Review profile**: CHILL
4750>
4751> **Plan**: Team
4752>
4753> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4754>
4755> </details>
4756
4757<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4758
4759<!-- tips_start -->
4760
4761---
4762
4763Thanks 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.
4764
4765<details>
4766<summary>❤️ Share</summary>
4767
4768- [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"####;
4769
4770 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4772
4773---
4774### Reviewing PR #16
4775
4776- [x] Read AGENTS.md conventions
4777- [x] Review `src/daemon.rs` changes
4778- [x] Review `src/main.rs` changes (new `doctor` reporting)
4779- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4780- [x] Check test coverage for new behavior
4781- [x] Run verification commands (blocked — see note)
4782- [x] Post findings"####;
4783
4784 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4786
4787---
4788### Review: `magi review <branch>` — cheap-half-only graph
4789
4790Read 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.
4791
4792**Correctness**
4793
4794- 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"####;
4795
4796 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4797 PrState {
4798 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4799 number: 16,
4800 state: PrLifecycle::Open,
4801 checks,
4802 blocking: if matches!(checks, Checks::Red) {
4806 Blocking::Yes
4807 } else {
4808 Blocking::No
4809 },
4810 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4811 review_comments: (0..comments)
4812 .map(|i| ReviewComment {
4813 author: "coderabbitai".to_owned(),
4814 path: Some("src/graph.rs".to_owned()),
4815 line: Some(231),
4816 body: format!("finding {i}"),
4817 })
4818 .collect(),
4819 }
4820 }
4821
4822 #[test]
4823 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4824 use CiExpectation::{Absent, Expected};
4825 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4826 let p = pr(checks, &["x"], 0);
4827 for waited in [Duration::ZERO, CHECKS_GRACE] {
4828 assert_eq!(
4829 decide_with(&p, 0, 4, waited, Expected),
4830 decide(&p, 0, 4, waited)
4831 );
4832 }
4833 }
4834 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4836 let p = pr(checks, &["x"], 0);
4837 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4838 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4839 }
4840 let mut p = pr(Checks::Unknown, &[], 0);
4842 p.blocking = Blocking::Conflict;
4843 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4844 p.state = PrLifecycle::Merged;
4846 assert_eq!(
4847 decide_with(&p, 0, 4, Duration::ZERO, Absent),
4848 Step::Done { merged: true }
4849 );
4850 }
4851
4852 #[test]
4853 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4854 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4855 assert_eq!(state.number, 10);
4856 assert_eq!(state.state, PrLifecycle::Open);
4857 assert_eq!(state.checks, Checks::Green);
4858 assert!(state.failing.is_empty());
4859 assert!(
4860 state.review_comments.is_empty(),
4861 "the only comment is CodeRabbit's trigger notice: {:?}",
4862 state.review_comments
4863 );
4864 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4865 }
4866
4867 #[test]
4868 fn a_failing_check_parses_as_red_and_is_named() {
4869 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4870 assert_eq!(state.checks, Checks::Red);
4871 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4872 let mut blocking = state.clone();
4879 blocking.blocking = Blocking::Yes;
4880 match decide(&blocking, 0, 4, Duration::ZERO) {
4881 Step::Fix { reason } => {
4882 assert!(reason.contains("editorconfig"), "reason: {reason}");
4883 assert!(reason.contains("failing"), "reason: {reason}");
4884 }
4885 other => panic!("expected a fix round, got {other:?}"),
4886 }
4887 }
4888
4889 #[test]
4890 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4891 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4892 assert_eq!(state.checks, Checks::Pending);
4893 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4894 }
4895
4896 #[test]
4897 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4898 let state = parse_pr(MERGED).expect("merged fixture parses");
4899 assert_eq!(state.state, PrLifecycle::Merged);
4900 assert_eq!(
4901 decide(&state, 0, 4, Duration::ZERO),
4902 Step::Done { merged: true }
4903 );
4904 }
4905
4906 #[test]
4907 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4908 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4909 assert_eq!(state.checks, Checks::Green);
4910 let authors: Vec<&str> = state
4911 .review_comments
4912 .iter()
4913 .map(|c| c.author.as_str())
4914 .collect();
4915 assert_eq!(
4916 authors,
4917 vec!["claude"],
4918 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4919 );
4920 match decide(&state, 0, 4, Duration::ZERO) {
4921 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4922 other => panic!("expected a fix round, got {other:?}"),
4923 }
4924 }
4925
4926 #[test]
4927 fn inline_review_comments_keep_their_file_and_line() {
4928 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4929 assert_eq!(comments.len(), 1);
4930 assert_eq!(comments[0].author, "claude[bot]");
4931 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4932 assert_eq!(comments[0].line, Some(231));
4933 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4934 }
4935
4936 #[test]
4937 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4938 assert!(
4939 is_noise(CODERABBIT_TRIGGER),
4940 "CodeRabbit's trigger notice declares itself not a review"
4941 );
4942 assert!(
4943 is_noise(CLAUDE_CHECKLIST),
4944 "a progress checklist asks for nothing"
4945 );
4946 assert!(
4947 !is_noise(CLAUDE_FINDING),
4948 "a review that names a bug is input, not noise"
4949 );
4950
4951 let mut clean = pr(Checks::Green, &[], 0);
4952 clean.review_comments.push(ReviewComment {
4953 author: "coderabbitai".to_owned(),
4954 path: None,
4955 line: None,
4956 body: CODERABBIT_TRIGGER.to_owned(),
4957 });
4958 clean.review_comments.retain(|c| !is_noise(&c.body));
4959 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4960
4961 let mut found = pr(Checks::Green, &[], 0);
4962 found.review_comments.push(ReviewComment {
4963 author: "claude".to_owned(),
4964 path: None,
4965 line: None,
4966 body: CLAUDE_FINDING.to_owned(),
4967 });
4968 found.review_comments.retain(|c| !is_noise(&c.body));
4969 assert!(matches!(
4970 decide(&found, 0, 4, Duration::ZERO),
4971 Step::Fix { .. }
4972 ));
4973 }
4974
4975 #[test]
4976 fn the_policy_table_holds_for_every_combination_that_matters() {
4977 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4978 (
4979 "pending checks are waited for, even on the last round",
4980 pr(Checks::Pending, &[], 0),
4981 4,
4982 4,
4983 Duration::ZERO,
4984 Step::Wait,
4985 ),
4986 (
4987 "red checks are fixed",
4988 pr(Checks::Red, &["editorconfig"], 0),
4989 0,
4990 4,
4991 Duration::ZERO,
4992 Step::Fix {
4993 reason: "1 check(s) failing: editorconfig".to_owned(),
4994 },
4995 ),
4996 (
4997 "green with comments is fixed, not merged",
4998 pr(Checks::Green, &[], 2),
4999 1,
5000 4,
5001 Duration::ZERO,
5002 Step::Fix {
5003 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5004 .to_owned(),
5005 },
5006 ),
5007 (
5008 "green and clean merges",
5009 pr(Checks::Green, &[], 0),
5010 3,
5011 4,
5012 Duration::ZERO,
5013 Step::Merge,
5014 ),
5015 (
5016 "an unreadable rollup is waited on while the grace lasts",
5017 pr(Checks::Unknown, &[], 0),
5018 0,
5019 4,
5020 Duration::ZERO,
5021 Step::Wait,
5022 ),
5023 (
5024 "an unreadable rollup is never merged once the grace is spent",
5025 pr(Checks::Unknown, &[], 0),
5026 0,
5027 4,
5028 CHECKS_GRACE,
5029 Step::GiveUp {
5030 reason: "no check status is readable on the pull request after 3 minute(s); \
5031 refusing to merge on a guess"
5032 .to_owned(),
5033 },
5034 ),
5035 ];
5036 for (what, state, round, budget, waited, want) in cases {
5037 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5038 }
5039 }
5040
5041 #[test]
5042 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5043 let green = parse_pr(GREEN_OPEN).expect("parse");
5047 assert_eq!(green.blocking, Blocking::No);
5048 let red = parse_pr(RED_OPEN).expect("parse");
5049 assert_eq!(
5050 red.blocking,
5051 Blocking::No,
5052 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5053 );
5054 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5055 let quiet =
5057 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5058 assert_eq!(quiet.blocking, Blocking::Unsaid);
5059 }
5060
5061 #[test]
5062 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5063 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5069 nonblocking.blocking = Blocking::No;
5070 assert_eq!(
5071 decide(&nonblocking, 0, 4, Duration::ZERO),
5072 Step::Merge,
5073 "the forge says nothing is in the way, so nothing is"
5074 );
5075
5076 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5078 blocking.blocking = Blocking::Yes;
5079 assert!(matches!(
5080 decide(&blocking, 0, 4, Duration::ZERO),
5081 Step::Fix { .. }
5082 ));
5083
5084 let mut commented = pr(Checks::Red, &["coverage"], 1);
5087 commented.blocking = Blocking::No;
5088 assert!(matches!(
5089 decide(&commented, 0, 4, Duration::ZERO),
5090 Step::Fix { .. }
5091 ));
5092
5093 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5095 unsaid.blocking = Blocking::Unsaid;
5096 assert!(matches!(
5097 decide(&unsaid, 0, 4, Duration::ZERO),
5098 Step::Fix { .. }
5099 ));
5100 }
5101
5102 #[test]
5103 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5104 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5105 red.blocking = Blocking::No;
5106 assert_eq!(
5107 decide(&red, 0, 4, Duration::ZERO),
5108 Step::Merge,
5109 "announcing must not change the decision"
5110 );
5111 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5112 assert!(said.contains("yukimemi/magi"), "{said}");
5113 assert!(said.contains("#16"), "{said}");
5114 assert!(
5115 said.contains("https://github.com/yukimemi/magi/pull/16"),
5116 "{said}"
5117 );
5118 assert!(
5119 said.contains("test (windows-latest)") && said.contains("coverage"),
5120 "{said}"
5121 );
5122
5123 let green = pr(Checks::Green, &["stale"], 0);
5125 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5126 }
5127
5128 #[test]
5129 fn the_repo_label_comes_from_the_pull_request_url() {
5130 let p = Path::new("/tmp/checkout");
5131 assert_eq!(
5132 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5133 "yukimemi/magi"
5134 );
5135 assert_eq!(repo_label(p, "not a url"), "checkout");
5136 }
5137
5138 #[test]
5139 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5140 let mut conflicted = pr(Checks::Green, &[], 0);
5145 conflicted.blocking = Blocking::Conflict;
5146 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5147
5148 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5152 red.blocking = Blocking::Conflict;
5153 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5154
5155 let mut merged = pr(Checks::Red, &[], 0);
5157 merged.blocking = Blocking::Conflict;
5158 merged.state = PrLifecycle::Merged;
5159 assert_eq!(
5160 decide(&merged, 0, 4, Duration::ZERO),
5161 Step::Done { merged: true }
5162 );
5163 }
5164
5165 #[test]
5166 fn the_forge_verdict_is_read_off_merge_state_status() {
5167 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5170 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5171 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5172 }
5173 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5174 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5175 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5176 for quiet in ["", "UNKNOWN"] {
5179 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5180 assert!(Blocking::of(quiet).stops_a_merge());
5181 }
5182 }
5183
5184 #[test]
5185 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5186 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5187 let jj = "could not determine current branch: failed to run git: not on any branch";
5189
5190 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5191 .expect("the forge says merged, so it merged");
5192 assert!(landed.ok);
5193 assert!(
5194 landed.detail.contains("but the pull request is merged"),
5195 "the record must not read as a clean success: {}",
5196 landed.detail
5197 );
5198 assert!(
5199 landed.detail.contains("not on any branch"),
5200 "and it must keep what the command actually said: {}",
5201 landed.detail
5202 );
5203
5204 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5206 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5207 assert!(merged_after_all(&argv, jj, None).is_none());
5209 }
5210
5211 #[test]
5212 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5213 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5214 state.state = PrLifecycle::Closed;
5215 assert_eq!(
5216 decide(&state, 0, 4, Duration::ZERO),
5217 Step::Done { merged: false },
5218 "a human closing the pull request ends the loop, whatever CI says"
5219 );
5220 }
5221
5222 #[test]
5223 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5224 let red = decide(
5225 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5226 4,
5227 4,
5228 Duration::ZERO,
5229 );
5230 match red {
5231 Step::GiveUp { reason } => {
5232 assert!(reason.contains("editorconfig"), "reason: {reason}");
5233 assert!(reason.contains("test (macos)"), "reason: {reason}");
5234 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5235 }
5236 other => panic!("expected a give-up, got {other:?}"),
5237 }
5238
5239 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5240 match commented {
5241 Step::GiveUp { reason } => {
5242 assert!(reason.contains("unresolved"), "reason: {reason}");
5243 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5244 }
5245 other => panic!("expected a give-up, got {other:?}"),
5246 }
5247 }
5248
5249 #[test]
5250 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5251 let candidate_commit = "magi: candidate A (uncommitted work)";
5252 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5253 let argv = merge_argv(16, &subject);
5254
5255 assert!(argv.contains(&"--squash".to_owned()));
5256 assert!(argv.contains(&"--delete-branch".to_owned()));
5257 assert!(argv.contains(&"--subject".to_owned()));
5258 assert_eq!(
5259 argv.last().map(String::as_str),
5260 Some("add retries to the uploader"),
5261 "the subject must not be the candidate commit message"
5262 );
5263 assert_ne!(subject, candidate_commit);
5264 }
5265
5266 #[test]
5267 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5268 assert_eq!(
5269 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5270 "feat: a queue, an unattended loop, and a phone UI"
5271 );
5272 assert_eq!(
5273 merge_subject("", "# port the retry logic\n\ndetails"),
5274 "port the retry logic",
5275 "an empty title falls back to the task's first line, heading marks stripped"
5276 );
5277 }
5278
5279 #[test]
5280 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5281 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5282 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5283 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5284 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5285 assert_eq!(run_of(""), None);
5286 }
5287
5288 #[test]
5289 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5290 let mut out = Vec::new();
5291 push_if_outstanding(
5292 &mut out,
5293 ReviewComment {
5294 author: "yukimemi".to_owned(),
5295 path: None,
5296 line: None,
5297 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5298 },
5299 );
5300 assert!(out.is_empty());
5301 }
5302
5303 fn run_state() -> RunState {
5307 let mut state = RunState::new(
5308 std::path::PathBuf::from("/repo/magi"),
5309 "main".to_owned(),
5310 "abcdef1234".to_owned(),
5311 "add retries to the uploader".to_owned(),
5312 crate::config::Config::default(),
5313 );
5314 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5319 let nanos = std::time::SystemTime::now()
5320 .duration_since(std::time::UNIX_EPOCH)
5321 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5322 state.id = format!(
5323 "20261004-{nanos:06}-{:04x}",
5324 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5325 );
5326 state
5327 }
5328
5329 fn green_pr() -> PrState {
5330 PrState {
5331 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5332 number: 42,
5333 state: PrLifecycle::Open,
5334 checks: Checks::Green,
5335 blocking: Blocking::No,
5337 failing: Vec::new(),
5338 review_comments: vec![ReviewComment {
5339 author: "coderabbitai".to_owned(),
5340 path: Some("src/land.rs".to_owned()),
5341 line: Some(212),
5342 body: "this branch never checks the exit code".to_owned(),
5343 }],
5344 }
5345 }
5346
5347 #[test]
5348 fn github_facing_land_text_is_english_whatever_the_language() {
5349 let mut state = run_state();
5350 state.config.graph.language = "ja".to_owned();
5351 let comment = stop_comment(&state.id, "checks are still red");
5352 assert!(comment.is_ascii(), "{comment}");
5353 assert!(comment.starts_with(MARKER));
5354
5355 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5356 let ja_at = p.find("Write all prose in ja").unwrap();
5357 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5358 assert!(ja_at < rule_at, "{p}");
5359 assert!(p.contains("stays in Japanese"), "{p}");
5360
5361 state.config.graph.language = "en".to_owned();
5362 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5363 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5364 assert!(!p.contains("does not apply"), "{p}");
5365 }
5366
5367 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5368
5369 fn panel() -> String {
5370 approval_panel(
5371 &run_state(),
5372 &green_pr(),
5373 NUMSTAT,
5374 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5375 &[
5376 "land: ask before merging".to_owned(),
5377 "land: colour the diff".to_owned(),
5378 ],
5379 "feat: merge approval from the phone",
5380 )
5381 }
5382
5383 #[test]
5384 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5385 let html = panel();
5386 for needle in [
5387 "42",
5388 "main",
5389 "src/land.rs",
5390 "src/web.rs",
5391 "assets/logo.png",
5392 "feat: merge approval from the phone",
5393 "land: ask before merging",
5394 "land: colour the diff",
5395 "coderabbitai",
5396 "this branch never checks the exit code",
5397 "green",
5398 ] {
5399 assert!(html.contains(needle), "the panel must state `{needle}`");
5400 }
5401 }
5402
5403 fn winning_candidate(summary: &str) -> Candidate {
5406 Candidate {
5407 index: 0,
5408 label: 'A',
5409 agent: "opus".to_owned(),
5410 branch: "magi/x/A".to_owned(),
5411 worktree: PathBuf::from("/wt/A"),
5412 summary: summary.to_owned(),
5413 stat: String::new(),
5414 files: 1,
5415 commits: 1,
5416 empty: false,
5417 failed: None,
5418 verified_noop: None,
5419 duration_ms: 0,
5420 folded: false,
5421 }
5422 }
5423
5424 fn uncontested_tally() -> Tally {
5425 Tally {
5426 first_choice: BTreeMap::from([('A', 1)]),
5427 borda: BTreeMap::new(),
5428 winner: 'A',
5429 rankings: 1,
5430 unanimous_initial: true,
5431 deliberated: false,
5432 changed_votes: 0,
5433 unanimous_final: true,
5434 tie_break: None,
5435 judges: 1,
5436 present: 1,
5437 quorum: 1,
5438 met_quorum: true,
5439 uncontested: None,
5440 }
5441 }
5442
5443 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5444 ReviewRecord {
5445 attempts: 0,
5446 reviewer,
5447 agent: agent.to_owned(),
5448 summary: summary.to_owned(),
5449 findings: Vec::new(),
5450 vote: None,
5451 failed: None,
5452 duration_ms: 0,
5453 }
5454 }
5455
5456 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5457 let answered = reviews.len();
5458 ReviewRound {
5459 round,
5460 head: "abc1234".to_owned(),
5461 verified_head: None,
5462 verified_at: None,
5463 reviews,
5464 e2e: Vec::new(),
5465 verify_retried: false,
5466 e2e_deferred: false,
5467 e2e_defer_reason: None,
5468 fix: None,
5469 blocking: 0,
5470 answered,
5471 expected: answered,
5472 clean: true,
5473 progressed: false,
5474 vote_split: false,
5475 reconsideration: Vec::new(),
5476 verdict: None,
5477 }
5478 }
5479
5480 #[test]
5481 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5482 let en = panel();
5483 assert!(en.contains("Task"), "{en}");
5484 assert!(en.contains("add retries to the uploader"), "{en}");
5485
5486 let mut state = run_state();
5487 state.config.graph.language = "ja".to_owned();
5488 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5489 assert!(ja.contains("タスク"), "{ja}");
5490 assert!(
5491 ja.contains("add retries to the uploader"),
5492 "the task itself is not translated: {ja}"
5493 );
5494 }
5495
5496 #[test]
5497 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5498 let html = panel();
5502 assert!(!html.contains("What changed"), "{html}");
5503 assert!(!html.contains("Review verdict"), "{html}");
5504 }
5505
5506 #[test]
5507 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5508 let mut state = run_state();
5509 state.candidates = vec![winning_candidate("")];
5510 state.tally = Some(uncontested_tally());
5511 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5512 assert!(
5513 !html.contains("What changed"),
5514 "an empty summary must not render an empty box: {html}"
5515 );
5516 }
5517
5518 #[test]
5519 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5520 let mut state = run_state();
5521 state.candidates = vec![winning_candidate(
5522 "Added a retry loop around the uploader PUT call.",
5523 )];
5524 state.tally = Some(uncontested_tally());
5525 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5526 assert!(en.contains("What changed"), "{en}");
5527 assert!(
5528 en.contains("Added a retry loop around the uploader PUT call."),
5529 "{en}"
5530 );
5531
5532 state.config.graph.language = "ja".to_owned();
5533 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5534 assert!(ja.contains("変更内容"), "{ja}");
5535 assert!(
5536 ja.contains("Added a retry loop around the uploader PUT call."),
5537 "{ja}"
5538 );
5539 }
5540
5541 #[test]
5542 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5543 let mut state = run_state();
5544 state.reviews = vec![
5545 review_round(
5546 1,
5547 vec![review_record(1, "alpha", "found a race, sent back")],
5548 ),
5549 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5550 ];
5551 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5552 assert!(en.contains("Review verdict"), "{en}");
5553 assert!(en.contains("race is fixed, clean"), "{en}");
5554 assert!(
5555 !en.contains("found a race, sent back"),
5556 "only the round that actually cleared the merge should show: {en}"
5557 );
5558
5559 state.config.graph.language = "ja".to_owned();
5560 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5561 assert!(ja.contains("レビューの結論"), "{ja}");
5562 assert!(ja.contains("レビュアー"), "{ja}");
5563 assert!(ja.contains("race is fixed, clean"), "{ja}");
5564 }
5565
5566 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5572 ReviewRecord {
5573 attempts: 0,
5574 reviewer,
5575 agent: agent.to_owned(),
5576 summary: String::new(),
5577 findings: Vec::new(),
5578 vote: None,
5579 failed: Some(reason.to_owned()),
5580 duration_ms: 0,
5581 }
5582 }
5583
5584 #[test]
5585 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5586 let mut state = run_state();
5587 state.reviews = vec![review_round(
5588 1,
5589 vec![
5590 review_record(1, "alpha", "clean, nothing to add"),
5591 unanswered_review_record(2, "beta", "timed out"),
5592 ],
5593 )];
5594 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5595 assert!(en.contains("clean, nothing to add"), "{en}");
5596 assert!(
5597 en.contains("produced no answer: timed out"),
5598 "a seat that never answered must say so, not render a blank box: {en}"
5599 );
5600 assert!(
5601 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5602 "no reviewer box may be left empty: {en}"
5603 );
5604
5605 state.config.graph.language = "ja".to_owned();
5606 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5607 assert!(ja.contains("回答なし: timed out"), "{ja}");
5608 }
5609
5610 #[test]
5611 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5612 let html = panel();
5613 assert!(!html.contains("<script"), "no script survives the csp");
5614 assert!(!html.contains("<form"), "form-action is 'none'");
5615 let pr = green_pr();
5616 assert_eq!(
5617 html.matches("http").count(),
5618 html.matches(pr.url.as_str()).count(),
5619 "the only http url in the panel is the pull request's own link"
5620 );
5621 }
5622
5623 #[test]
5624 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5625 let html = panel();
5626 assert!(
5627 html.contains(">+</span>"),
5628 "an added line carries a `+` in the gutter, not only a background"
5629 );
5630 assert!(
5631 html.contains(">-</span>"),
5632 "a removed line carries a `-` in the gutter, not only a background"
5633 );
5634 assert!(
5635 html.contains(">new line</span>"),
5636 "the marker is moved to the gutter, so the body is printed once without it"
5637 );
5638 }
5639
5640 #[test]
5641 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5642 let total = DIFF_MAX_LINES + 100;
5643 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5644 let html = approval_panel(
5645 &run_state(),
5646 &green_pr(),
5647 NUMSTAT,
5648 &diff,
5649 &[],
5650 "feat: something long",
5651 );
5652 assert!(
5653 html.contains(&format!("100 of {total} diff lines omitted")),
5654 "the note must say exactly how much was cut"
5655 );
5656 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5657 assert!(
5658 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5659 "nothing past the threshold is rendered"
5660 );
5661 assert!(
5662 html.contains("/repo/magi"),
5663 "the note says where the rest is"
5664 );
5665 }
5666
5667 #[test]
5668 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5669 let html = approval_panel(
5670 &run_state(),
5671 &green_pr(),
5672 "1\t2\tsrc/<b>&\"x\"'.rs",
5673 "",
5674 &[],
5675 "subject",
5676 );
5677 assert!(html.contains("src/<b>&"x"'.rs"));
5678 assert!(
5679 !html.contains("<b>"),
5680 "an agent-influenced path must never become markup"
5681 );
5682 }
5683
5684 #[tokio::test]
5685 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5686 let a = std::path::PathBuf::from("/repo/a");
5687 let b = std::path::PathBuf::from("/repo/b");
5688
5689 let held = repo_merge_lock(&a).lock_owned().await;
5690
5691 assert!(
5694 repo_merge_lock(&a).try_lock().is_err(),
5695 "a second merge into the same repository must not proceed concurrently"
5696 );
5697
5698 assert!(
5702 repo_merge_lock(&b).try_lock().is_ok(),
5703 "a different repository's merge lock must be independent"
5704 );
5705
5706 drop(held);
5707 assert!(
5708 repo_merge_lock(&a).try_lock().is_ok(),
5709 "the lock is released once the holder is done"
5710 );
5711 }
5712
5713 #[test]
5714 fn only_the_merge_choice_merges_and_silence_holds() {
5715 let table = [
5716 (None, Approval::Hold),
5717 (Some("merge"), Approval::Merge),
5718 (Some(" merge\n"), Approval::Merge),
5719 (Some("hold"), Approval::Hold),
5720 (Some(""), Approval::Hold),
5721 (Some("yes"), Approval::Hold),
5722 ];
5723 for (answer, want) in table {
5724 assert_eq!(
5725 approval(answer),
5726 want,
5727 "answer {answer:?} must resolve to {want:?}"
5728 );
5729 }
5730 }
5731
5732 #[tokio::test]
5733 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5734 let mut state = landing_state();
5735 state.config.graph.land_approval = true;
5736 let pr = green_pr();
5737
5738 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5739 .await
5740 .unwrap();
5741 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5742 assert!(
5743 !state.parked,
5744 "approval_gate itself never sets `parked`; only its caller does"
5745 );
5746
5747 let store = ask::Questions::open();
5748 let filed: Vec<_> = store
5749 .list()
5750 .into_iter()
5751 .filter(|q| q.run == state.id)
5752 .collect();
5753 assert_eq!(filed.len(), 1, "exactly one question is filed");
5754 assert_eq!(filed[0].node, APPROVAL_NODE);
5755 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5756 assert!(filed[0].status.open());
5757
5758 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5762 .await
5763 .unwrap();
5764 assert_eq!(again, ApprovalGate::Pending);
5765 let still_one = store
5766 .list()
5767 .into_iter()
5768 .filter(|q| q.run == state.id)
5769 .count();
5770 assert_eq!(
5771 still_one, 1,
5772 "asking twice must not double-file the question"
5773 );
5774 }
5775
5776 #[tokio::test]
5777 async fn approving_the_existing_question_is_read_back_as_approved() {
5778 crate::run::pin_test_home();
5779 let mut state = run_state();
5780 state.config.graph.land_approval = true;
5781 let pr = green_pr();
5782 assert_eq!(
5783 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5784 .await
5785 .unwrap(),
5786 ApprovalGate::Pending
5787 );
5788
5789 let store = ask::Questions::open();
5790 let mut q = store
5791 .list()
5792 .into_iter()
5793 .find(|q| q.run == state.id)
5794 .expect("filed above");
5795 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5796 store.put(&mut q).unwrap();
5797
5798 assert_eq!(
5799 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5800 .await
5801 .unwrap(),
5802 ApprovalGate::Approved
5803 );
5804 }
5805
5806 #[tokio::test]
5807 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5808 crate::run::pin_test_home();
5809 let store = ask::Questions::open();
5810
5811 let mut held_state = run_state();
5812 held_state.config.graph.land_approval = true;
5813 let pr = green_pr();
5814 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5815 .await
5816 .unwrap();
5817 let mut q = store
5818 .list()
5819 .into_iter()
5820 .find(|q| q.run == held_state.id)
5821 .expect("filed above");
5822 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5823 store.put(&mut q).unwrap();
5824 assert_eq!(
5825 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5826 .await
5827 .unwrap(),
5828 ApprovalGate::Held
5829 );
5830
5831 let mut abandoned_state = run_state();
5832 abandoned_state.config.graph.land_approval = true;
5833 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5834 .await
5835 .unwrap();
5836 let mut q = store
5837 .list()
5838 .into_iter()
5839 .find(|q| q.run == abandoned_state.id)
5840 .expect("filed above");
5841 q.abandon("no answer within the timeout");
5842 store.put(&mut q).unwrap();
5843 assert_eq!(
5844 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5845 .await
5846 .unwrap(),
5847 ApprovalGate::Held,
5848 "silence must never merge"
5849 );
5850 }
5851
5852 fn contested() -> ContestedHandoff {
5853 let finding = |id: &str, n: u32| crate::verdict::Finding {
5854 id: id.to_owned(),
5855 severity: crate::verdict::Severity::Major,
5856 file: Some("src/a.rs".to_owned()),
5857 line: Some(n),
5858 title: format!("problem {id}"),
5859 detail: String::new(),
5860 };
5861 ContestedHandoff {
5862 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5863 rejecters: vec![(1, "alpha".to_owned())],
5864 }
5865 }
5866
5867 #[test]
5868 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5869 let mut state = run_state();
5870 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5871 state.contested_handoff = Some(contested());
5872 assert!(contested_to_ask(&state).is_some());
5873 state.config.graph.hold_contested_merge = false;
5874 assert!(
5875 contested_to_ask(&state).is_none(),
5876 "the switch restores today"
5877 );
5878 }
5879
5880 #[test]
5881 fn merge_intent_wants_a_clear_unhedged_quote_and_holds_on_doubt() {
5882 let yes = [
5883 ("merge", "merge"),
5884 (" Merge ", "Merge"),
5885 (
5886 "マージしていいよ。残りのレビュー指摘はフォローアップタスクとして積んで",
5887 "マージしていいよ",
5888 ),
5889 (
5890 "Merge it. Please file the remaining findings as follow-ups.",
5891 "Merge it",
5892 ),
5893 ("Note the findings and merge now", "merge now"),
5894 ("I know the risk, merge it", "merge it"),
5895 ];
5896 for (msg, quote) in yes {
5897 assert!(merge_intent(msg, quote), "{msg:?} / {quote:?}");
5898 }
5899 let no = [
5900 ("たぶんマージでいい", "たぶんマージでいい"),
5901 (
5902 "マージしていいかも。フォローアップ積んで",
5903 "マージしていいかも",
5904 ),
5905 ("maybe merge it", "merge it"),
5906 ("probably fine to merge", "merge"),
5907 ("merge if CI is green", "merge"),
5908 ("CIが通ったらマージして", "マージして"),
5909 ("merge, but not the docs change", "merge"),
5910 ("don't merge", "merge"),
5911 ("merge?", "merge"),
5912 ("マージしていい?", "マージしていい"),
5913 ("merge it. wait, actually hold on", "merge it"),
5914 ("マージして。やっぱりやめた", "マージして"),
5915 ("please file follow-ups", "follow-ups"),
5916 (
5917 "Merge it. Only if CI passes. Queue the remaining findings.",
5918 "Merge it",
5919 ),
5920 ("マージして。CIが通ったらね。", "マージして"),
5921 ("Merge it. Don't.", "Merge it"),
5922 ("Merge it. Hold on a sec.", "Merge it"),
5923 ("マージして。でも保留で", "マージして"),
5924 ("マージしていいよ、でもdocsは触らないで", "マージしていいよ"),
5925 (
5926 "Merge once CI passes. Queue the remaining findings.",
5927 "Merge once CI passes",
5928 ),
5929 ("Merge provided CI passes.", "Merge provided CI passes"),
5930 ("CIが通り次第マージして", "マージして"),
5931 ("Merge it. No, stop.", "Merge it"),
5932 ("Merge it. Stop.", "Merge it"),
5933 ("Merge it. Nope.", "Merge it"),
5934 ("merge it, don't", "merge it"),
5935 ("merge it, dont", "merge it"),
5936 ("マージして。いや、やめて", "マージして"),
5937 (
5942 "Merge now. File a follow-up task to fix R1-1 after this PR merges.",
5943 "Merge now",
5944 ),
5945 ("merge it", "go ahead"),
5946 ("merge it", "merge it please"),
5947 ("merge it", " "),
5948 ];
5949 for (msg, quote) in no {
5950 assert!(!merge_intent(msg, quote), "{msg:?} / {quote:?}");
5951 }
5952 }
5953
5954 #[test]
5955 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5956 let q = ask::Question::new(
5957 "run-1".to_owned(),
5958 APPROVAL_NODE.to_owned(),
5959 "land".to_owned(),
5960 "Merge?".to_owned(),
5961 String::new(),
5962 vec![APPROVE.to_owned(), HOLD.to_owned()],
5963 );
5964 let none = deputy_brief(&q, None);
5965 assert!(none.contains("could not be read"), "{none}");
5966 assert!(none.contains("Silence is a hold"), "{none}");
5967
5968 let mut state = run_state();
5969 state.pr = Some(crate::run::PrRecord {
5970 url: "https://example.test/pull/7".to_owned(),
5971 number: 7,
5972 state: "open".to_owned(),
5973 checks: "green".to_owned(),
5974 round: 0,
5975 rounds: 3,
5976 red_at_merge: Vec::new(),
5977 });
5978 state.contested_handoff = Some(contested());
5979 let b = deputy_brief(&q, Some(&state));
5980 assert!(b.contains("https://example.test/pull/7"), "{b}");
5981 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5982 assert!(b.contains("#1"), "the rejecting seat: {b}");
5983 state.contested_handoff = None;
5984 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5985 }
5986
5987 #[test]
5988 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5989 for lang in ["en", "ja"] {
5990 let mut cfg = crate::config::Config::default();
5991 cfg.graph.language = lang.to_owned();
5992 let w = words(&cfg.graph.language);
5993 let text = w.approval_detail(
5994 "https://github.com/yukimemi/magi/pull/42",
5995 "main",
5996 "feat: x",
5997 Some(&contested()),
5998 );
5999 assert!(text.contains("pull/42"), "{text}");
6000 assert!(
6001 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6002 "{text}"
6003 );
6004 assert!(text.contains("R3-1-5"), "{text}");
6005 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6006 assert!(text.contains("2"), "the rest are counted: {text}");
6007 assert!(text.contains("#1 (alpha)"), "{text}");
6008 }
6009 let plain = words("en").approval_detail("u", "main", "s", None);
6010 assert!(!plain.contains("reject"), "{plain}");
6011 }
6012
6013 #[tokio::test]
6014 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6015 crate::run::pin_test_home();
6016 let mut state = run_state();
6017 state.config.graph.land_approval = false;
6018 state.contested_handoff = Some(contested());
6019 let pr = green_pr();
6020 let c = contested_to_ask(&state);
6021 assert_eq!(
6022 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6023 .await
6024 .unwrap(),
6025 ApprovalGate::Pending,
6026 "silence is a hold"
6027 );
6028 let store = ask::Questions::open();
6029 let filed: Vec<_> = store
6030 .list()
6031 .into_iter()
6032 .filter(|q| q.run == state.id)
6033 .collect();
6034 assert_eq!(filed.len(), 1);
6035 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6036
6037 assert_eq!(
6038 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6039 .await
6040 .unwrap(),
6041 ApprovalGate::Pending
6042 );
6043 let mut q = store
6044 .list()
6045 .into_iter()
6046 .find(|q| q.run == state.id)
6047 .unwrap();
6048 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6049 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6050 store.put(&mut q).unwrap();
6051 assert_eq!(
6052 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6053 .await
6054 .unwrap(),
6055 ApprovalGate::Approved
6056 );
6057 }
6058
6059 #[test]
6060 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6061 let rows = parse_numstat(NUMSTAT);
6062 assert_eq!(
6063 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6064 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6065 );
6066 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6067 }
6068 #[test]
6069 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6070 let mut state = run_state();
6074 state.config.graph.language = "ja".to_owned();
6075 let pr = green_pr();
6076 let commits = ["c1".to_owned()];
6077
6078 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6079 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6080 assert!(ja.contains("squash されるコミット"), "{ja}");
6081 assert!(ja.contains("レビューコメント"), "{ja}");
6082 assert!(ja.contains("差分"), "{ja}");
6083 assert!(
6084 !ja.contains("Commits being squashed"),
6085 "no English left over"
6086 );
6087
6088 let w = words("ja");
6089 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6090 assert!(
6091 w.approval_detail("http://x/1", "main", "feat: x", None)
6092 .contains("パネル")
6093 );
6094
6095 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6097 assert!(ja.contains("feat: x"), "nor is the merge subject");
6098
6099 state.config.graph.language = "en".to_owned();
6102 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6103 assert!(en.contains("Commits being squashed"), "{en}");
6104 assert_eq!(words("Klingon").html_lang, "en");
6105 }
6106
6107 #[test]
6111 fn pick_open_pr_classifies_by_count_and_base() {
6112 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6113 assert_eq!(
6114 pick_open_pr(one, "main").unwrap(),
6115 OpenPr::One {
6116 url: "https://x/pull/58".into(),
6117 title: "t".into()
6118 }
6119 );
6120 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6121 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6122 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6123 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6124 assert_eq!(
6125 pick_open_pr(two, "main").unwrap(),
6126 OpenPr::Many(vec!["u1".into(), "u2".into()])
6127 );
6128 assert!(pick_open_pr("not json", "main").is_err());
6129 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6131 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6132 }
6133
6134 #[test]
6135 fn pick_merged_pr_picks_the_unique_match() {
6136 let json = r#"[
6137 {"url": "https://github.com/o/r/pull/42", "number": 42,
6138 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6139 ]"#;
6140 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6141 let found = pick_merged_pr(json, "main", created_at)
6142 .expect("valid json")
6143 .expect("one unambiguous match");
6144 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6145 assert_eq!(found.number, 42);
6146 }
6147
6148 #[test]
6152 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6153 let json = r#"[
6154 {"url": "https://github.com/o/r/pull/42", "number": 42,
6155 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6156 {"url": "https://github.com/o/r/pull/43", "number": 43,
6157 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6158 ]"#;
6159 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6160 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6161 }
6162
6163 #[test]
6167 fn pick_merged_pr_ignores_a_different_base_branch() {
6168 let json = r#"[
6169 {"url": "https://github.com/o/r/pull/42", "number": 42,
6170 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6171 ]"#;
6172 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6173 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6174 }
6175
6176 #[test]
6179 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6180 let json = r#"[
6181 {"url": "https://github.com/o/r/pull/42", "number": 42,
6182 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6183 ]"#;
6184 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6185 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6186 }
6187
6188 #[test]
6189 fn slug_of_pr_url_reads_host_owner_and_repo() {
6190 assert_eq!(
6191 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6192 Some("github.com/yukimemi/shun")
6193 );
6194 }
6195
6196 #[test]
6197 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6198 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6199 assert_eq!(slug_of_pr_url("not a url at all"), None);
6200 assert_eq!(slug_of_pr_url("https://github.com"), None);
6201 }
6202
6203 #[test]
6204 fn slug_of_repo_url_reads_host_owner_and_repo() {
6205 assert_eq!(
6206 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6207 Some("github.com/yukimemi/magi")
6208 );
6209 assert_eq!(slug_of_repo_url("https://github.com"), None);
6210 }
6211
6212 #[test]
6213 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6214 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6215 .expect("same repo, different case");
6216 }
6217
6218 #[test]
6223 fn ensure_same_repo_refuses_a_different_repo() {
6224 let err =
6225 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6226 let msg = format!("{err:#}");
6227 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6228 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6229 }
6230
6231 #[test]
6236 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6237 let err = ensure_same_repo(
6238 "github.com/yukimemi/magi",
6239 "github.example.com/yukimemi/magi",
6240 )
6241 .unwrap_err();
6242 let msg = format!("{err:#}");
6243 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6244 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6245 }
6246
6247 #[tokio::test]
6252 async fn find_external_merge_returns_none_without_a_winner() {
6253 let state = RunState::new(
6254 PathBuf::from("/no/such/repo"),
6255 "main".to_owned(),
6256 "0000000000000000000000000000000000000000".to_owned(),
6257 "irrelevant".to_owned(),
6258 crate::config::Config::default(),
6259 );
6260 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6261 }
6262
6263 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6264 let mut run = RunState::new(
6265 PathBuf::from(repo),
6266 "main".to_owned(),
6267 "abcdef1234".to_owned(),
6268 "x".to_owned(),
6269 crate::config::Config::default(),
6270 );
6271 run.id = id.to_owned();
6272 run.status = status;
6273 run.pr = Some(crate::run::PrRecord {
6274 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6275 url: url.to_owned(),
6276 state: state.to_owned(),
6277 checks: "red".to_owned(),
6278 round: 0,
6279 rounds: 2,
6280 red_at_merge: Vec::new(),
6281 });
6282 run.save_under(home).unwrap();
6283 }
6284
6285 fn recorded(home: &Path, id: &str) -> String {
6286 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6287 serde_json::from_str::<RunState>(&body)
6288 .unwrap()
6289 .pr
6290 .unwrap()
6291 .state
6292 }
6293
6294 const PR: &str = "https://github.com/o/r/pull/7";
6295
6296 #[test]
6297 fn write_through_updates_predecessors_and_siblings_only() {
6298 let tmp = tempfile::tempdir().unwrap();
6299 let h = tmp.path();
6300 pr_run(
6301 h,
6302 "20261004-100000-aaaa",
6303 "/repo/r",
6304 RunStatus::Superseded,
6305 PR,
6306 "open",
6307 );
6308 pr_run(
6309 h,
6310 "20261004-100100-bbbb",
6311 "/repo/r",
6312 RunStatus::Blocked,
6313 PR,
6314 "open",
6315 );
6316 pr_run(
6318 h,
6319 "20261004-100200-cccc",
6320 "/repo/r",
6321 RunStatus::Landing,
6322 PR,
6323 "open",
6324 );
6325 pr_run(
6327 h,
6328 "20261004-100300-dddd",
6329 "/repo/other",
6330 RunStatus::Blocked,
6331 "https://github.com/o/other/pull/7",
6332 "open",
6333 );
6334 pr_run(
6336 h,
6337 "20261004-100400-eeee",
6338 "/repo/r",
6339 RunStatus::Blocked,
6340 "https://github.com/o/r/pull/8",
6341 "open",
6342 );
6343 pr_run(
6344 h,
6345 "20261004-100500-ffff",
6346 "/repo/r",
6347 RunStatus::Merged,
6348 PR,
6349 "open",
6350 );
6351 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6352
6353 assert_eq!(
6354 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6355 2
6356 );
6357 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6358 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6359 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6360 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6361 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6362 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6364 assert_eq!(
6366 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6367 0
6368 );
6369 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6370 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6371 }
6372
6373 #[test]
6374 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6375 let tmp = tempfile::tempdir().unwrap();
6376 let h = tmp.path();
6377 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6378 pr_run(
6379 h,
6380 "20261004-100000-aaaa",
6381 "/repo/r",
6382 RunStatus::Superseded,
6383 &url(1),
6384 "open",
6385 );
6386 pr_run(
6387 h,
6388 "20261004-100100-bbbb",
6389 "/repo/r",
6390 RunStatus::Blocked,
6391 &url(2),
6392 "open",
6393 );
6394 pr_run(
6395 h,
6396 "20261004-100200-cccc",
6397 "/repo/r",
6398 RunStatus::Ready,
6399 &url(3),
6400 "open",
6401 );
6402 pr_run(
6403 h,
6404 "20261004-100300-dddd",
6405 "/repo/r",
6406 RunStatus::Ready,
6407 &url(4),
6408 "open",
6409 );
6410 pr_run(
6411 h,
6412 "20261004-100400-eeee",
6413 "/repo/r",
6414 RunStatus::Implementing,
6415 &url(1),
6416 "open",
6417 );
6418 assert_eq!(stale_open_prs(h).len(), 4);
6419
6420 let mut known = BTreeMap::new();
6421 known.insert(url(1), PrLifecycle::Merged);
6422 known.insert(url(2), PrLifecycle::Closed);
6423 known.insert(url(3), PrLifecycle::Open);
6424 assert_eq!(apply_pr_states(h, &known), 2);
6426 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6427 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6428 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6429 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6430 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6431 assert_eq!(apply_pr_states(h, &known), 0);
6432 }
6433
6434 use std::collections::VecDeque;
6437 use std::sync::Mutex;
6438
6439 struct Scripted {
6442 views: Mutex<VecDeque<Seen>>,
6443 merges: Mutex<VecDeque<(bool, String)>>,
6444 fix: Mutex<Option<Fixed>>,
6445 log: Mutex<Vec<&'static str>>,
6446 argvs: Mutex<Vec<Vec<String>>>,
6447 required: Mutex<Option<BTreeSet<String>>>,
6448 merged: Mutex<bool>,
6451 queued: Mutex<bool>,
6452 unreadable_after_merge: Mutex<bool>,
6454 }
6455
6456 impl Scripted {
6457 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6458 Self {
6459 views: Mutex::new(views.into()),
6460 merges: Mutex::new(
6461 merges
6462 .into_iter()
6463 .map(|(ok, m)| (ok, m.to_owned()))
6464 .collect(),
6465 ),
6466 fix: Mutex::new(None),
6467 log: Mutex::new(Vec::new()),
6468 argvs: Mutex::new(Vec::new()),
6469 required: Mutex::new(None),
6470 merged: Mutex::new(false),
6471 queued: Mutex::new(false),
6472 unreadable_after_merge: Mutex::new(false),
6473 }
6474 }
6475 fn argvs(&self) -> Vec<Vec<String>> {
6476 self.argvs.lock().unwrap().clone()
6477 }
6478 fn calls(&self) -> Vec<&'static str> {
6479 self.log.lock().unwrap().clone()
6480 }
6481 }
6482
6483 impl Forge for Scripted {
6484 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6485 self.log.lock().unwrap().push("view");
6486 if *self.unreadable_after_merge.lock().unwrap()
6487 && !self.argvs.lock().unwrap().is_empty()
6488 {
6489 anyhow::bail!("forge unreachable");
6490 }
6491 let mut v = self.views.lock().unwrap();
6492 let mut seen = if v.len() > 1 {
6493 v.pop_front().unwrap()
6494 } else {
6495 v[0].clone()
6496 };
6497 if *self.merged.lock().unwrap() {
6498 seen.pr.state = PrLifecycle::Merged;
6499 }
6500 Ok(seen)
6501 }
6502 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6503 self.log.lock().unwrap().push("merge");
6504 self.argvs.lock().unwrap().push(argv.to_vec());
6505 let out = self
6506 .merges
6507 .lock()
6508 .unwrap()
6509 .pop_front()
6510 .expect("unscripted merge");
6511 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6512 {
6513 *self.merged.lock().unwrap() = true;
6514 }
6515 Ok(out)
6516 }
6517 async fn poll(&self) {
6518 self.log.lock().unwrap().push("poll");
6519 }
6520 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6521 self.required.lock().unwrap().clone()
6522 }
6523 async fn fix(
6524 &self,
6525 _state: &mut RunState,
6526 _pr: &PrState,
6527 _round: usize,
6528 _budget: usize,
6529 _reason: &str,
6530 _logs: &str,
6531 ) -> Result<Fixed> {
6532 self.log.lock().unwrap().push("fix");
6533 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6534 }
6535 }
6536
6537 const REFUSED: &str =
6538 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6539
6540 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6541 let mut pr = green_pr();
6542 pr.checks = checks;
6543 pr.blocking = Blocking::of(merge_state);
6544 if !comments {
6545 pr.review_comments.clear();
6546 }
6547 Seen {
6548 pr,
6549 title: "feat: x".to_owned(),
6550 failing_urls: Vec::new(),
6551 head: head.to_owned(),
6552 rollup_head: head.to_owned(),
6553 merge_state: merge_state.to_owned(),
6554 contexts: Vec::new(),
6555 base: "main".to_owned(),
6556 }
6557 }
6558
6559 fn landing_state() -> RunState {
6560 crate::run::pin_test_home();
6561 let mut state = run_state();
6562 state.config.graph.land_approval = false;
6563 state
6564 }
6565
6566 #[test]
6567 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6568 assert!(!awaiting_new_head(None, "aaa"));
6569 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6570 assert!(awaiting_new_head(Some("abc123"), "def456"));
6571 assert!(awaiting_new_head(Some("abc123"), ""));
6572 }
6573
6574 #[test]
6575 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6576 let open = |c, m: &str| seen("a", c, m, false);
6577 let table = [
6578 (None, false, Refused::Pending),
6579 (
6580 Some(open(Checks::Pending, "BLOCKED")),
6581 false,
6582 Refused::Pending,
6583 ),
6584 (
6585 Some(open(Checks::Unknown, "BLOCKED")),
6586 false,
6587 Refused::Pending,
6588 ),
6589 (
6590 Some(open(Checks::Green, "UNKNOWN")),
6591 false,
6592 Refused::Pending,
6593 ),
6594 (Some(open(Checks::Green, "")), false, Refused::Pending),
6595 (
6596 Some(open(Checks::Green, "BLOCKED")),
6597 false,
6598 Refused::Recheck,
6599 ),
6600 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6601 ];
6602 for (after, rechecked, want) in table {
6603 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6604 }
6605 let mut closed = open(Checks::Green, "CLEAN");
6606 closed.pr.state = PrLifecycle::Closed;
6607 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6608 }
6609
6610 #[tokio::test]
6611 async fn a_normal_landing_merges_on_the_first_look() {
6612 let mut state = landing_state();
6613 let forge = Scripted::new(
6614 vec![seen("a", Checks::Green, "CLEAN", false)],
6615 vec![(true, "")],
6616 );
6617 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6618 .await
6619 .unwrap();
6620 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6622 assert_eq!(state.status, RunStatus::Merged);
6623 }
6624
6625 #[tokio::test]
6626 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6627 let mut state = landing_state();
6628 let forge = Scripted::new(
6629 vec![seen("a", Checks::Green, "CLEAN", false)],
6630 std::iter::repeat_n((true, ""), 100).collect(),
6631 );
6632 *forge.queued.lock().unwrap() = true;
6633 let task = async {
6634 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6635 .await
6636 .unwrap();
6637 };
6638 task.await;
6641 assert_ne!(state.status, RunStatus::Merged);
6642 }
6643
6644 #[tokio::test]
6645 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6646 let mut state = landing_state();
6647 let forge = Scripted::new(
6648 vec![seen("a", Checks::Green, "CLEAN", false)],
6649 std::iter::repeat_n((true, ""), 100).collect(),
6650 );
6651 *forge.unreadable_after_merge.lock().unwrap() = true;
6652 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6653 .await
6654 .ok();
6655 assert_ne!(state.status, RunStatus::Merged);
6656 }
6657
6658 #[tokio::test]
6659 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6660 let mut state = landing_state();
6661 let forge = Scripted::new(
6662 vec![
6663 seen("old", Checks::Green, "CLEAN", true),
6664 seen("old", Checks::Green, "CLEAN", true),
6666 seen("new", Checks::Pending, "BLOCKED", true),
6667 seen("new", Checks::Green, "CLEAN", true),
6668 ],
6669 vec![(true, "")],
6670 );
6671 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6672 head: "NEW".to_owned(),
6673 });
6674 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6675 .await
6676 .unwrap();
6677 assert_eq!(
6678 forge.calls(),
6679 [
6680 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6681 "view"
6682 ]
6683 );
6684 assert_eq!(state.status, RunStatus::Merged);
6685 }
6686
6687 #[tokio::test]
6688 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6689 let mut state = landing_state();
6690 let forge = Scripted::new(
6691 vec![
6692 seen("old", Checks::Green, "CLEAN", true),
6693 seen("someone-elses", Checks::Green, "CLEAN", true),
6694 ],
6695 vec![],
6696 );
6697 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6698 head: "mine".to_owned(),
6699 });
6700 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6701 .await
6702 .unwrap();
6703 assert!(!forge.calls().contains(&"merge"));
6704 let why = state.merge.as_ref().unwrap().detail.clone();
6705 assert!(
6706 why.contains("mine") && why.contains("someone-elses"),
6707 "{why}"
6708 );
6709 assert_eq!(state.status, RunStatus::Blocked);
6710 }
6711
6712 #[tokio::test]
6713 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6714 let mut state = landing_state();
6715 let forge = Scripted::new(
6716 vec![
6717 seen("a", Checks::Green, "CLEAN", false),
6718 seen("a", Checks::Green, "CLEAN", false),
6719 seen("a", Checks::Pending, "BLOCKED", false),
6720 seen("a", Checks::Pending, "BLOCKED", false),
6721 seen("a", Checks::Green, "CLEAN", false),
6722 ],
6723 vec![(false, REFUSED), (true, "")],
6724 );
6725 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6726 .await
6727 .unwrap();
6728 assert_eq!(
6729 forge.calls(),
6730 [
6731 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6732 "view"
6733 ]
6734 );
6735 assert_eq!(state.status, RunStatus::Merged);
6736 }
6737
6738 #[tokio::test]
6739 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6740 let mut state = landing_state();
6741 let forge = Scripted::new(
6742 vec![
6743 seen("a", Checks::Green, "CLEAN", false),
6744 seen("a", Checks::Green, "BLOCKED", false),
6745 ],
6746 vec![(false, REFUSED), (false, REFUSED)],
6747 );
6748 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6749 .await
6750 .unwrap();
6751 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6753 let why = state.merge.as_ref().unwrap().detail.clone();
6754 assert!(
6755 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6756 "{why}"
6757 );
6758 assert_eq!(state.status, RunStatus::Blocked);
6759 }
6760
6761 #[test]
6762 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6763 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6764 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6765 assert_eq!(bound_head("old", "old", Some("new")), None);
6767 assert_eq!(bound_head("new", "old", Some("new")), None);
6769 assert_eq!(bound_head("new", "old", None), None);
6770 assert_eq!(bound_head("", "", None), None);
6772 assert_eq!(bound_head("", "", Some("new")), None);
6773 assert_eq!(bound_head("abc", "", None), None);
6774 }
6775
6776 fn view_json(head: &str) -> String {
6777 format!(
6778 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6779 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6780 "reviews":[],"comments":[]}}"#
6781 )
6782 }
6783
6784 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6785 format!(
6786 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6787 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6788 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6789 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6790 )
6791 }
6792
6793 #[test]
6794 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6795 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6798 assert_eq!(s.rollup_head, "old");
6799 assert_eq!(s.pr.checks, Checks::Red);
6800 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6801 assert_eq!(s.failing_urls.len(), 1);
6802 }
6803
6804 #[test]
6805 fn checks_come_from_the_node_not_the_view() {
6806 let view = view_json("new").replace(
6807 r#""reviews""#,
6808 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6809 );
6810 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6811 assert_eq!(s.pr.checks, Checks::Green);
6812 assert!(s.pr.failing.is_empty());
6813 assert_eq!(
6814 bound_head(&s.head, &s.rollup_head, Some("new")),
6815 Some("new")
6816 );
6817 }
6818
6819 #[test]
6820 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6821 for node in [
6822 None,
6823 Some("not json".to_owned()),
6824 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6825 Some(node_json("new", "SUCCESS", true)),
6826 ] {
6827 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6828 assert!(s.rollup_head.is_empty());
6829 assert_eq!(s.pr.checks, Checks::Unknown);
6830 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6831 }
6832 }
6833
6834 #[test]
6835 fn the_merge_command_is_pinned_to_the_observed_head() {
6836 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6837 let at = argv
6838 .iter()
6839 .position(|a| a == "--match-head-commit")
6840 .unwrap();
6841 assert_eq!(argv[at + 1], "deadbeef");
6842 }
6843
6844 #[tokio::test]
6845 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6846 let mut state = landing_state();
6847 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6850 stale.rollup_head = "old".to_owned();
6851 let forge = Scripted::new(
6852 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6853 vec![],
6854 );
6855 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6856 head: "new".to_owned(),
6857 });
6858 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6859 .await
6860 .unwrap();
6861 assert!(!forge.calls().contains(&"merge"));
6862 assert_eq!(state.status, RunStatus::Blocked);
6863 let why = state.merge.as_ref().unwrap().detail.clone();
6864 assert!(why.contains("new") && why.contains("old"), "{why}");
6865 }
6866
6867 #[test]
6868 fn a_refusal_read_against_another_commits_checks_is_pending() {
6869 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6870 after.rollup_head = "old".to_owned();
6871 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6872 }
6873
6874 #[tokio::test]
6875 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6876 let mut state = landing_state();
6877 let forge = Scripted::new(
6878 vec![seen("a", Checks::Red, "CLEAN", false)],
6879 vec![(true, "")],
6880 );
6881 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6882 .await
6883 .unwrap();
6884 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6885 assert_eq!(state.status, RunStatus::Merged);
6886 }
6887
6888 #[tokio::test]
6889 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6890 let mut state = landing_state();
6891 let forge = Scripted::new(
6892 vec![
6893 seen("a", Checks::Green, "CLEAN", false),
6894 seen("a", Checks::Green, "CLEAN", false),
6895 seen("b", Checks::Green, "BLOCKED", false),
6897 seen("b", Checks::Green, "CLEAN", false),
6898 ],
6899 vec![(false, REFUSED), (true, "")],
6900 );
6901 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6902 .await
6903 .unwrap();
6904 assert_eq!(
6905 forge.calls(),
6906 [
6907 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6908 ]
6909 );
6910 assert_eq!(state.status, RunStatus::Merged);
6911 }
6912
6913 fn merged_view(head: &str) -> Seen {
6914 let mut m = seen(head, Checks::Green, "CLEAN", false);
6915 m.pr.state = PrLifecycle::Merged;
6916 m
6917 }
6918
6919 fn has(argv: &[String], flag: &str) -> bool {
6920 argv.iter().any(|a| a == flag)
6921 }
6922
6923 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6924 let at = argv.iter().position(|a| a == flag)?;
6925 argv.get(at + 1).map(String::as_str)
6926 }
6927
6928 const URL: &str = "https://github.com/o/r/pull/42";
6929
6930 #[tokio::test]
6931 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6932 let mut state = landing_state();
6933 let forge = Scripted::new(
6934 vec![
6935 seen("abc", Checks::Green, "CLEAN", false),
6936 seen("abc", Checks::Green, "CLEAN", false),
6937 ],
6938 vec![(true, "")],
6939 );
6940 land_with(&mut state, URL, &forge).await.unwrap();
6941 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6942 let argv = &forge.argvs()[0];
6943 assert!(has(argv, "--squash") && has(argv, "--subject"));
6944 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6945 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6946 assert_eq!(state.status, RunStatus::Merged);
6947 assert!(state.land_armed_head.is_none());
6948 }
6949
6950 #[tokio::test]
6951 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6952 let mut state = landing_state();
6953 state.land_armed_head = Some("a".to_owned());
6954 let forge = Scripted::new(
6955 vec![seen("a", Checks::Green, "CLEAN", false)],
6956 vec![(true, ""), (true, "")],
6957 );
6958 land_with(&mut state, URL, &forge).await.unwrap();
6959 let argvs = forge.argvs();
6960 assert!(has(&argvs[0], "--disable-auto"));
6961 assert!(!has(&argvs[1], "--auto"));
6962 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6963 assert_eq!(state.status, RunStatus::Merged);
6964 assert!(state.land_armed_head.is_none());
6965 }
6966
6967 #[tokio::test]
6968 async fn an_approval_never_carries_over_to_a_new_head() {
6969 crate::run::pin_test_home();
6970 let mut state = run_state();
6971 state.config.graph.land_approval = true;
6972 let pr = green_pr();
6973 let store = ask::Questions::open();
6974
6975 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6976 .await
6977 .unwrap();
6978 let mut q = store
6979 .list()
6980 .into_iter()
6981 .find(|q| q.run == state.id)
6982 .unwrap();
6983 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6984 store.put(&mut q).unwrap();
6985 assert_eq!(
6986 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6987 .await
6988 .unwrap(),
6989 ApprovalGate::Approved,
6990 "the same head keeps its approval"
6991 );
6992
6993 assert_eq!(
6995 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6996 .await
6997 .unwrap(),
6998 ApprovalGate::Pending
6999 );
7000 let all: Vec<_> = store
7001 .list()
7002 .into_iter()
7003 .filter(|q| q.run == state.id)
7004 .collect();
7005 assert_eq!(all.len(), 2);
7006
7007 state.land_approval = None;
7009 assert_eq!(
7010 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7011 .await
7012 .unwrap(),
7013 ApprovalGate::Pending
7014 );
7015 let open = store
7016 .list()
7017 .into_iter()
7018 .filter(|q| q.run == state.id && q.status.open())
7019 .count();
7020 assert_eq!(open, 1, "the superseded question was retired");
7021 }
7022
7023 #[tokio::test]
7024 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7025 let mut state = landing_state();
7026 let forge = Scripted::new(
7027 vec![
7028 seen("a", Checks::Green, "CLEAN", false),
7029 seen("b", Checks::Green, "CLEAN", false),
7031 ],
7032 vec![(true, "")],
7033 );
7034 land_with(&mut state, URL, &forge).await.unwrap();
7035 let argvs = forge.argvs();
7036 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7037 assert!(!has(&argvs[0], "--auto"));
7038 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7039 assert_eq!(state.status, RunStatus::Merged);
7040 }
7041
7042 fn passing(label: &str) -> CheckInfo {
7043 CheckInfo {
7044 label: label.to_owned(),
7045 verdict: Verdict::Pass,
7046 required: Some(false),
7047 }
7048 }
7049
7050 fn names(xs: &[&str]) -> BTreeSet<String> {
7051 xs.iter().map(|x| (*x).to_owned()).collect()
7052 }
7053
7054 #[test]
7055 fn a_required_check_the_rollup_never_listed_is_named() {
7056 let req = names(&["build"]);
7057 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7058 assert!(why.contains("never reported: build"), "{why}");
7059 assert!(!why.contains("probably waiting for a review"), "{why}");
7060 }
7061
7062 #[test]
7063 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7064 let why = waiting_on("BLOCKED", &[passing("review")], None);
7065 assert!(why.contains("could not be read"), "{why}");
7066 assert!(!why.contains("probably waiting for a review"), "{why}");
7067 }
7068
7069 #[test]
7070 fn all_required_reported_keeps_the_review_guess() {
7071 let req = names(&["build"]);
7072 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7073 assert!(why.contains("probably waiting for a review"), "{why}");
7074 assert!(!why.contains("never reported"), "{why}");
7075 }
7076
7077 #[test]
7078 fn required_names_match_the_rollup_ignoring_case_only() {
7079 let req = names(&["Build"]);
7080 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7081 assert!(!why.contains("never reported"), "{why}");
7082 }
7083
7084 #[test]
7085 fn required_contexts_are_read_from_protection_and_rulesets() {
7086 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7087 assert_eq!(
7088 parse_classic_required(classic),
7089 Some(names(&["build", "lint"]))
7090 );
7091 let rules = r#"[{"type":"pull_request","parameters":{}},
7092 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7093 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7094 assert_eq!(parse_ruleset_required("nope"), None);
7095 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7096 }
7097
7098 #[test]
7099 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7100 let shown = BTreeSet::new();
7101 let ok = seen("a", Checks::Green, "CLEAN", false);
7102 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7103 assert!(guard(Some(&ok)));
7104 assert!(!guard(None));
7105 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7106 let mut stale = ok.clone();
7107 stale.rollup_head = "old".to_owned();
7108 assert!(!guard(Some(&stale)));
7109 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7110 assert!(!guard(Some(&merged_view("a"))));
7111 }
7112
7113 #[test]
7114 fn the_rollup_node_carries_whether_each_check_is_required() {
7115 let node = node_json("new", "SUCCESS", false)
7116 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7117 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7118 assert_eq!(s.contexts.len(), 1);
7119 assert_eq!(s.contexts[0].required, Some(true));
7120 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7121 assert_eq!(s.contexts[0].required, None);
7122 }
7123
7124 #[tokio::test]
7125 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7126 let mut state = landing_state();
7127 state.land_armed_head = Some("a".to_owned());
7128 let forge = Scripted::new(
7129 vec![seen("a", Checks::Green, "CLEAN", true)],
7130 vec![(false, "disable exploded")],
7131 );
7132 land_with(&mut state, URL, &forge).await.unwrap();
7133 assert!(!forge.calls().contains(&"fix"));
7134 assert_eq!(state.status, RunStatus::Blocked);
7135 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7136 let why = state.merge.as_ref().unwrap().detail.clone();
7137 assert!(why.contains("disable exploded"), "{why}");
7138 }
7139}