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