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