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(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2145 let (title, _) = crate::github_text::prepare(state, title, "");
2146 let (ok, out) = gh(
2147 repo,
2148 &[
2149 "pr".to_owned(),
2150 "edit".to_owned(),
2151 url.to_owned(),
2152 "--title".to_owned(),
2153 title.to_owned(),
2154 ],
2155 )
2156 .await?;
2157 if !ok {
2158 bail!("gh pr edit failed: {out}");
2159 }
2160 Ok(())
2161}
2162
2163pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2176 let Some(winner) = state.winner() else {
2177 return Ok(None);
2178 };
2179 let branch = winner.branch.clone();
2180 let out = gh(
2181 &state.repo,
2182 &[
2183 "pr".to_owned(),
2184 "list".to_owned(),
2185 "--head".to_owned(),
2186 branch.clone(),
2187 "--state".to_owned(),
2188 "merged".to_owned(),
2189 "--json".to_owned(),
2190 "url,number,mergedAt,baseRefName".to_owned(),
2191 ],
2192 )
2193 .await?;
2194 if !out.0 {
2195 bail!("gh pr list --head {branch}: {}", out.1);
2196 }
2197 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2198}
2199
2200pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2212 let out = tokio::process::Command::new("git")
2213 .args(["merge-base", "--is-ancestor", branch, base_branch])
2214 .current_dir(repo)
2215 .quiet()
2216 .stdin(std::process::Stdio::null())
2217 .output()
2218 .await
2219 .context("spawn git merge-base --is-ancestor")?;
2220 Ok(out.status.success())
2221}
2222
2223fn forge_slug(url: &str) -> Option<(String, &str)> {
2231 let rest = url.rsplit("://").next()?;
2232 let (host, path) = rest.split_once('/')?;
2233 if host.is_empty() {
2234 return None;
2235 }
2236 Some((host.to_ascii_lowercase(), path))
2237}
2238
2239pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2247 let (host, path) = forge_slug(url)?;
2248 let mut segments = path.split('/');
2249 let owner = segments.next()?;
2250 let repo = segments.next()?;
2251 let kind = segments.next()?;
2252 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2253 return None;
2254 }
2255 Some(format!("{host}/{owner}/{repo}"))
2256}
2257
2258fn slug_of_repo_url(url: &str) -> Option<String> {
2262 let (host, path) = forge_slug(url)?;
2263 let mut segments = path.split('/');
2264 let owner = segments.next()?;
2265 let repo = segments.next()?;
2266 if owner.is_empty() || repo.is_empty() {
2267 return None;
2268 }
2269 Some(format!("{host}/{owner}/{repo}"))
2270}
2271
2272pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2293 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2294 return Ok(());
2295 }
2296 bail!(
2297 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2298 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2299 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2300 verify by hand before treating this as a false positive)"
2301 );
2302}
2303
2304async fn repo_slug(repo: &Path) -> Result<String> {
2315 let out = gh(
2316 repo,
2317 &[
2318 "repo".to_owned(),
2319 "view".to_owned(),
2320 "--json".to_owned(),
2321 "url".to_owned(),
2322 ],
2323 )
2324 .await?;
2325 if !out.0 {
2326 bail!("gh repo view --json url: {}", out.1);
2327 }
2328 #[derive(Debug, Deserialize)]
2329 struct GhRepo {
2330 url: String,
2331 }
2332 let parsed: GhRepo = serde_json::from_str(&out.1)
2333 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2334 slug_of_repo_url(&parsed.url)
2335 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2336}
2337
2338pub async fn correct_manual_merge(
2386 state: &mut RunState,
2387 url: &str,
2388) -> Result<(RunStatus, RunStatus)> {
2389 let Some(pr_slug) = slug_of_pr_url(url) else {
2390 bail!(
2391 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2392 this pull request belongs to"
2393 );
2394 };
2395 let run_slug = repo_slug(&state.repo).await?;
2396 ensure_same_repo(&run_slug, &pr_slug)?;
2397 correct_merge(state, url).await
2398}
2399
2400pub(crate) async fn correct_confirmed_external_merge(
2411 state: &mut RunState,
2412 url: &str,
2413) -> Result<(RunStatus, RunStatus)> {
2414 correct_merge(state, url).await
2415}
2416
2417fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2419 let Some(pr) = a.pr.as_ref() else {
2420 return false;
2421 };
2422 if !url.is_empty()
2423 && pr
2424 .url
2425 .trim_end_matches('/')
2426 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2427 {
2428 return true;
2429 }
2430 number > 0
2431 && pr.number == number
2432 && match (a.repo.canonicalize(), repo.canonicalize()) {
2433 (Ok(x), Ok(y)) => x == y,
2434 _ => a.repo == repo,
2435 }
2436}
2437
2438fn rewrite_open_prs(
2451 home: &Path,
2452 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2453) -> usize {
2454 let now = Timestamp::now();
2455 let mut changed = 0;
2456 for id in crate::run::list_ids_in(&home.join("runs")) {
2457 let path = home.join("runs").join(&id).join("run.json");
2458 let Ok(body) = std::fs::read_to_string(&path) else {
2459 continue;
2460 };
2461 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2462 continue;
2463 };
2464 if !state.status.done()
2465 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2466 || crate::daemon::is_working_on(home, &id, now)
2467 {
2468 continue;
2469 }
2470 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2471 continue;
2472 };
2473 if let Some(pr) = state.pr.as_mut() {
2474 pr.state = to.as_str().to_owned();
2475 }
2476 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2477 state.event(
2478 "land",
2479 format!("recorded {url} as {}: another run settled it", to.as_str()),
2480 );
2481 match state.save_under(home) {
2482 Ok(()) => changed += 1,
2483 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2484 }
2485 }
2486 changed
2487}
2488
2489pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2494 if to == PrLifecycle::Open {
2495 return;
2496 }
2497 let Some(home) = crate::run::try_home() else {
2498 return;
2499 };
2500 write_pr_state_through_in(&home, run, to);
2501}
2502
2503pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2504 let Some(pr) = run.pr.as_ref() else {
2505 return 0;
2506 };
2507 let (url, number) = (pr.url.clone(), pr.number);
2508 rewrite_open_prs(home, &mut |other| {
2509 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2510 })
2511}
2512
2513pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2516 let now = Timestamp::now();
2517 let mut out = Vec::new();
2518 for id in crate::run::list_ids_in(&home.join("runs")) {
2519 let path = home.join("runs").join(&id).join("run.json");
2520 let Ok(body) = std::fs::read_to_string(&path) else {
2521 continue;
2522 };
2523 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2524 continue;
2525 };
2526 if let Some(pr) = state.pr.as_ref()
2527 && state.status.done()
2528 && pr.state == "open"
2529 && !pr.url.is_empty()
2530 && !crate::daemon::is_working_on(home, &id, now)
2531 {
2532 out.push((id, state.repo.clone(), pr.url.clone()));
2533 }
2534 }
2535 out
2536}
2537
2538pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2541 rewrite_open_prs(home, &mut |s| {
2542 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2543 })
2544}
2545
2546pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2553 let mut known = BTreeMap::new();
2554 let mut failed = 0;
2555 let mut seen = BTreeSet::new();
2556 for (_, repo, url) in stale_open_prs(home) {
2557 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2558 continue;
2559 }
2560 match lifecycle(&repo, &url).await {
2561 Ok(state) => {
2562 known.insert(url, state);
2563 }
2564 Err(e) => {
2565 tracing::warn!("repair pr state of {url}: {e:#}");
2566 failed += 1;
2567 }
2568 }
2569 }
2570 (apply_pr_states(home, &known), failed)
2571}
2572
2573async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2574 match lifecycle(&state.repo, url).await? {
2575 PrLifecycle::Merged => {}
2576 other => bail!(
2577 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2578 other.as_str(),
2579 state.id
2580 ),
2581 }
2582 let before = state.status;
2583 if let Err(e) = land(state, url).await {
2584 state.status = RunStatus::Blocked;
2591 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2592 state.save()?;
2593 return Err(e).context(format!("confirming the merge of {url}"));
2594 }
2595 state.event(
2596 "fold",
2597 "operator recorded this pull request as a manual merge; this run never \
2598 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2599 bump this change might warrant has to be filed by hand",
2600 );
2601 if state.status == RunStatus::Merged {
2604 crate::followup::after_merge(state, url).await;
2605 }
2606 state.save()?;
2607 Ok((before, state.status))
2608}
2609
2610pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2617 let raw: Vec<GhInline> =
2618 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2619 let mut out = Vec::new();
2620 for c in raw {
2621 push_if_outstanding(
2622 &mut out,
2623 ReviewComment {
2624 author: c.user.login,
2625 path: c.path,
2626 line: c.line,
2627 body: c.body,
2628 },
2629 );
2630 }
2631 Ok(out)
2632}
2633
2634fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2640 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2641 return;
2642 }
2643 if comment.path.is_none() && is_noise(&comment.body) {
2644 return;
2645 }
2646 out.push(comment);
2647}
2648
2649pub fn is_noise(body: &str) -> bool {
2667 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2668 return true;
2669 }
2670 let mut content = false;
2671 for line in strip_blocks(body).lines() {
2672 let line = unquote(line);
2673 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2674 continue;
2675 }
2676 content = true;
2677 break;
2678 }
2679 !content
2680}
2681
2682fn strip_blocks(body: &str) -> String {
2684 let mut out = String::with_capacity(body.len());
2685 let mut rest = body;
2686 loop {
2687 let open = ["<!--", "<details>"]
2688 .iter()
2689 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2690 .min_by_key(|(i, _)| *i);
2691 let Some((at, tag)) = open else {
2692 out.push_str(rest);
2693 return out;
2694 };
2695 out.push_str(&rest[..at]);
2696 let after = &rest[at + tag.len()..];
2697 let close = if tag == "<!--" { "-->" } else { "</details>" };
2698 match after.find(close) {
2699 Some(end) => rest = &after[end + close.len()..],
2700 None => return out,
2702 }
2703 }
2704}
2705
2706fn unquote(line: &str) -> &str {
2708 let mut s = line.trim();
2709 while let Some(rest) = s.strip_prefix('>') {
2710 s = rest.trim_start();
2711 }
2712 s.trim()
2713}
2714
2715fn is_checklist(line: &str) -> bool {
2717 let rest = line
2718 .strip_prefix("- ")
2719 .or_else(|| line.strip_prefix("* "))
2720 .unwrap_or("");
2721 let rest = rest.trim_start();
2722 matches!(
2723 rest.get(..3),
2724 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2725 )
2726}
2727
2728fn is_decoration(line: &str) -> bool {
2730 line.starts_with('#')
2731 || line.starts_with("[!")
2732 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2733}
2734
2735fn is_banner(line: &str) -> bool {
2742 let plain = drop_spans(line, "**", "**");
2743 let plain = if plain.contains("](") {
2744 drop_spans(&plain, "[", ")")
2745 } else {
2746 plain
2747 };
2748 !plain.chars().any(char::is_alphanumeric)
2749}
2750
2751fn drop_spans(s: &str, open: &str, close: &str) -> String {
2755 let mut out = String::with_capacity(s.len());
2756 let mut rest = s;
2757 while let Some(at) = rest.find(open) {
2758 out.push_str(&rest[..at]);
2759 let after = &rest[at + open.len()..];
2760 match after.find(close) {
2761 Some(end) => rest = &after[end + close.len()..],
2762 None => return out,
2763 }
2764 }
2765 out.push_str(rest);
2766 out
2767}
2768
2769fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2787 static LOCKS: std::sync::LazyLock<
2788 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2789 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2790 LOCKS
2791 .lock()
2792 .unwrap_or_else(std::sync::PoisonError::into_inner)
2793 .entry(repo.to_path_buf())
2794 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2795 .clone()
2796}
2797
2798fn repo_label(repo: &Path, pr_url: &str) -> String {
2801 let parts: Vec<&str> = pr_url.split('/').collect();
2802 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2803 && at >= 2
2804 && !parts[at - 1].is_empty()
2805 && !parts[at - 2].is_empty()
2806 {
2807 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2808 }
2809 repo.file_name()
2810 .map(|n| n.to_string_lossy().into_owned())
2811 .unwrap_or_default()
2812}
2813
2814fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2818 (pr.checks == Checks::Red).then(|| {
2819 format!(
2820 "Merged {repo_name} PR #{} with red checks: {} ({})",
2821 pr.number,
2822 if pr.failing.is_empty() {
2823 "(none named)".to_owned()
2824 } else {
2825 pr.failing.join(", ")
2826 },
2827 pr.url
2828 )
2829 })
2830}
2831
2832async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2836 let repo_name = repo_label(&state.repo, &pr.url);
2837 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2838 return;
2839 };
2840 if let Some(rec) = state.pr.as_mut() {
2841 rec.red_at_merge = pr.failing.clone();
2842 }
2843 state.event("land", summary.clone());
2844 crate::notices::raise_with(
2847 crate::notices::merged_red(&state.id, &summary),
2848 &state.config.notify,
2849 );
2850}
2851
2852pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2859 land_with(state, pr_url, &GhForge).await
2860}
2861
2862trait Forge {
2866 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2867 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2868 async fn poll(&self);
2869 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2872 #[allow(clippy::too_many_arguments)]
2873 async fn fix(
2874 &self,
2875 state: &mut RunState,
2876 pr: &PrState,
2877 round: usize,
2878 budget: usize,
2879 reason: &str,
2880 logs: &str,
2881 ) -> Result<Fixed>;
2882}
2883
2884struct GhForge;
2885
2886impl Forge for GhForge {
2887 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2888 observe(repo, pr_url).await
2889 }
2890 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2891 gh(repo, argv).await
2892 }
2893 async fn poll(&self) {
2894 tokio::time::sleep(POLL).await;
2895 }
2896 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2897 required_contexts_of(repo, base).await
2898 }
2899 async fn fix(
2900 &self,
2901 state: &mut RunState,
2902 pr: &PrState,
2903 round: usize,
2904 budget: usize,
2905 reason: &str,
2906 logs: &str,
2907 ) -> Result<Fixed> {
2908 fix_round(state, pr, round, budget, reason, logs).await
2909 }
2910}
2911
2912fn encode_path_segment(s: &str) -> String {
2914 let mut out = String::new();
2915 for b in s.bytes() {
2916 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2917 out.push(b as char);
2918 } else {
2919 let _ = write!(out, "%{b:02X}");
2920 }
2921 }
2922 out
2923}
2924
2925async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2930 let enc = encode_path_segment(base);
2931 let mut all = BTreeSet::new();
2932 let classic = gh(
2935 repo,
2936 &[
2937 "api".to_owned(),
2938 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2939 ],
2940 )
2941 .await
2942 .ok()?;
2943 if classic.0 {
2944 all.extend(parse_classic_required(&classic.1)?);
2945 } else if !classic.1.contains("404") {
2946 return None;
2947 }
2948 let rules = gh(
2949 repo,
2950 &[
2951 "api".to_owned(),
2952 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2953 ],
2954 )
2955 .await
2956 .ok()?;
2957 if !rules.0 {
2958 return None;
2959 }
2960 all.extend(parse_ruleset_required(&rules.1)?);
2961 Some(all)
2962}
2963
2964fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2967 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2968 let mut out = BTreeSet::new();
2969 for c in v.get("contexts")?.as_array()? {
2970 out.insert(c.as_str()?.to_owned());
2971 }
2972 for c in v
2973 .get("checks")
2974 .and_then(|c| c.as_array())
2975 .into_iter()
2976 .flatten()
2977 {
2978 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
2979 out.insert(name.to_owned());
2980 }
2981 }
2982 Some(out)
2983}
2984
2985fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
2987 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2988 let mut out = BTreeSet::new();
2989 for rule in v.as_array()? {
2990 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
2991 continue;
2992 }
2993 let checks = rule
2994 .pointer("/parameters/required_status_checks")?
2995 .as_array()?;
2996 for c in checks {
2997 out.insert(c.get("context")?.as_str()?.to_owned());
2998 }
2999 }
3000 Some(out)
3001}
3002
3003fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3011 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3012}
3013
3014fn bound_head<'a>(
3024 seen_head: &'a str,
3025 rollup_head: &str,
3026 awaiting: Option<&str>,
3027) -> Option<&'a str> {
3028 if seen_head.is_empty()
3029 || awaiting_new_head(awaiting, seen_head)
3030 || !rollup_head.eq_ignore_ascii_case(seen_head)
3031 {
3032 return None;
3033 }
3034 Some(seen_head)
3035}
3036
3037#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3039enum Refused {
3040 Pending,
3042 Recheck,
3046 Final,
3049}
3050
3051fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3054 let Some(after) = after else {
3055 return Refused::Pending;
3057 };
3058 if after.pr.state != PrLifecycle::Open {
3059 return Refused::Final;
3060 }
3061 if !after.head.eq_ignore_ascii_case(observed_head) {
3064 return Refused::Pending;
3065 }
3066 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3068 return Refused::Pending;
3069 }
3070 let state = after.merge_state.to_ascii_uppercase();
3071 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3072 || state.is_empty()
3073 || state == "UNKNOWN"
3074 {
3075 return Refused::Pending;
3076 }
3077 if rechecked {
3078 Refused::Final
3079 } else {
3080 Refused::Recheck
3081 }
3082}
3083
3084async fn disarm<F: Forge>(
3089 forge: &F,
3090 state: &mut RunState,
3091 repo: &Path,
3092 number: u64,
3093) -> std::result::Result<(), String> {
3094 let argv = disable_automerge_argv(number);
3095 let out = {
3096 let merge_lock = repo_merge_lock(repo);
3097 let _merge_slot = merge_lock.lock().await;
3098 forge.merge(repo, &argv).await
3099 };
3100 match out {
3101 Ok((true, _)) => {
3102 state.land_armed_head = None;
3103 state.event("land", "auto-merge disabled");
3104 state.save().map_err(|e| format!("{e:#}"))?;
3105 Ok(())
3106 }
3107 Ok((false, msg)) => Err(msg),
3108 Err(e) => Err(format!("{e:#}")),
3109 }
3110}
3111
3112async fn stop_disarmed<F: Forge>(
3116 forge: &F,
3117 state: &mut RunState,
3118 repo: &Path,
3119 pr: &PrState,
3120 why: &str,
3121) -> Result<()> {
3122 if state.land_armed_head.is_none() {
3123 return stop(state, repo, pr, why).await;
3124 }
3125 match disarm(forge, state, repo, pr.number).await {
3126 Ok(()) => stop(state, repo, pr, why).await,
3127 Err(e) => {
3128 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3129 stop(state, repo, pr, &why).await
3130 }
3131 }
3132}
3133
3134async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3135 let repo = state.repo.clone();
3136 let budget = state.config.graph.land_rounds;
3137 let mut round = 0usize;
3138 let mut rebases = 0usize;
3141 let mut waited = Duration::ZERO;
3142 let mut shown: BTreeSet<String> = BTreeSet::new();
3147 let mut awaiting_head: Option<String> = None;
3151 let mut rechecked = false;
3153
3154 state.status = RunStatus::Landing;
3162 state.event("land", format!("watching {pr_url}"));
3163 state.save()?;
3164
3165 let mut resumed_armed = state.land_armed_head.is_some();
3169
3170 loop {
3171 let seen = forge.view(&repo, pr_url).await?;
3172 let mut pr = seen.pr.clone();
3173 pr.review_comments.retain(|c| !shown.contains(&c.body));
3174 state.pr = Some(crate::run::PrRecord {
3175 url: pr.url.clone(),
3176 number: pr.number,
3177 state: pr.state.as_str().to_owned(),
3178 checks: pr.checks.as_str().to_owned(),
3179 round,
3180 rounds: budget,
3181 red_at_merge: Vec::new(),
3182 });
3183 state.save()?;
3184
3185 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3186 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3191 let why = format!(
3192 "a previous pass may have armed auto-merge and it could not be disabled \
3193 on resume: {e}"
3194 );
3195 stop(state, &repo, &pr, &why).await?;
3196 return Ok(pr);
3197 }
3198 }
3199
3200 if pr.state == PrLifecycle::Open
3205 && !seen.head.is_empty()
3206 && state
3207 .land_armed_head
3208 .as_deref()
3209 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3210 {
3211 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3212 let why = format!(
3213 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3214 );
3215 stop(state, &repo, &pr, &why).await?;
3216 return Ok(pr);
3217 }
3218 }
3219
3220 if pr.state == PrLifecycle::Open {
3221 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3222 if waited >= WAIT_CEILING {
3223 let want = awaiting_head.as_deref().unwrap_or_default();
3224 let why = format!(
3225 "the pull request's checks were still not about one readable head after \
3226 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3227 someone may have pushed over it",
3228 WAIT_CEILING.as_secs() / 60,
3229 if want.is_empty() { "any" } else { want },
3230 if seen.head.is_empty() {
3231 "nothing readable"
3232 } else {
3233 &seen.head
3234 },
3235 if seen.rollup_head.is_empty() {
3236 "nothing readable"
3237 } else {
3238 &seen.rollup_head
3239 },
3240 );
3241 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3242 return Ok(pr);
3243 }
3244 waited += POLL;
3245 forge.poll().await;
3246 continue;
3247 }
3248 if awaiting_head.take().is_some() {
3251 waited = Duration::ZERO;
3254 }
3255 }
3256
3257 let step = decide(&pr, round, budget, waited);
3258 let armed_here = state
3263 .land_armed_head
3264 .as_deref()
3265 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3266 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3267 if waited >= WAIT_CEILING {
3268 let required = if seen.base.is_empty() {
3269 None
3270 } else {
3271 forge.required_contexts(&repo, &seen.base).await
3272 };
3273 let why = format!(
3274 "auto-merge was armed on {} but the pull request did not merge within {} \
3275 minutes ({})",
3276 seen.head,
3277 WAIT_CEILING.as_secs() / 60,
3278 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3279 );
3280 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3281 return Ok(pr);
3282 }
3283 waited += POLL;
3284 forge.poll().await;
3285 continue;
3286 }
3287 if armed_here && !matches!(step, Step::Done { .. }) {
3288 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3291 let why = format!("auto-merge could not be disabled: {e}");
3292 stop(state, &repo, &pr, &why).await?;
3293 return Ok(pr);
3294 }
3295 }
3296 match step {
3297 Step::Wait => {
3298 if waited >= WAIT_CEILING {
3299 let why = format!(
3300 "checks were still running after {} minutes",
3301 WAIT_CEILING.as_secs() / 60
3302 );
3303 stop(state, &repo, &pr, &why).await?;
3304 return Ok(pr);
3305 }
3306 waited += POLL;
3307 forge.poll().await;
3308 }
3309 Step::Done { merged } => {
3310 if let Some(armed) = state.land_armed_head.take() {
3315 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3316 let msg = format!(
3317 "{} merged on {} but the owner approved {armed}; review what landed",
3318 pr.url, seen.head
3319 );
3320 tracing::warn!("{msg}");
3321 state.event("land", msg);
3322 crate::notices::raise_with(
3326 crate::notices::Notice::warn(
3327 &format!("merged-unapproved-head:{}", state.id),
3328 "A pull request merged on a commit the owner did not approve; \
3329 review what landed",
3330 )
3331 .link(crate::notices::Link::Run {
3332 id: state.id.clone(),
3333 }),
3334 &state.config.notify,
3335 );
3336 }
3337 }
3338 state.status = if merged {
3339 RunStatus::Merged
3340 } else {
3341 RunStatus::Ready
3342 };
3343 let detail = if merged {
3344 format!("{} was merged", pr.url)
3345 } else {
3346 format!("{} was closed without merging", pr.url)
3347 };
3348 state.merge = Some(MergeOutcome {
3349 mode: MergeMode::Pr,
3350 ok: merged,
3351 detail: detail.clone(),
3352 empty: false,
3353 });
3354 state.event("land", detail);
3355 state.save()?;
3356 write_pr_state_through(state, pr.state);
3357 return Ok(pr);
3358 }
3359 Step::Merge => {
3360 let subject = merge_subject(
3361 crate::graph::landing_title(state, &seen.title),
3362 &crate::graph::landing_subject_source(state),
3363 );
3364 let contested = contested_to_ask(state);
3371 if state.config.graph.land_approval || contested.is_some() {
3372 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3373 .await?
3374 {
3375 ApprovalGate::Approved => {}
3376 ApprovalGate::Held => {
3377 stop(
3378 state,
3379 &repo,
3380 &pr,
3381 "the owner did not approve the merge (held or unanswered)",
3382 )
3383 .await?;
3384 return Ok(pr);
3385 }
3386 ApprovalGate::Pending => {
3394 state.parked = true;
3395 state.event(
3396 "land",
3397 "parked awaiting merge approval - resumes once answered",
3398 );
3399 state.save()?;
3400 return Ok(pr);
3401 }
3402 }
3403 }
3404 let observed_head = seen.head.clone();
3415 {
3418 let fresh = forge.view(&repo, pr_url).await.ok();
3419 if !direct_merge_is_safe(
3420 fresh.as_ref(),
3421 &observed_head,
3422 &shown,
3423 round,
3424 budget,
3425 waited,
3426 ) {
3427 if waited >= WAIT_CEILING {
3428 let why = "the pull request did not settle on the approved head \
3429 before it could be merged";
3430 stop(state, &repo, &pr, why).await?;
3431 return Ok(pr);
3432 }
3433 state.event(
3434 "land",
3435 "the pull request changed before merging; looking again",
3436 );
3437 waited += POLL;
3438 forge.poll().await;
3439 continue;
3440 }
3441 }
3442 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3443 let out = {
3444 let merge_lock = repo_merge_lock(&repo);
3445 let _merge_slot = merge_lock.lock().await;
3446 forge.merge(&repo, &argv).await?
3447 };
3448 if out.0 {
3449 let confirmed = forge
3455 .view(&repo, pr_url)
3456 .await
3457 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3458 if !confirmed {
3459 if waited >= WAIT_CEILING {
3460 let why = "the merge request succeeded but the pull request \
3461 could not be confirmed merged after waiting";
3462 stop(state, &repo, &pr, why).await?;
3463 return Ok(pr);
3464 }
3465 state.event(
3466 "land",
3467 "merge accepted but the pull request is not confirmed merged yet; waiting",
3468 );
3469 state.save()?;
3470 waited += POLL;
3471 forge.poll().await;
3472 continue;
3473 }
3474 pr.state = PrLifecycle::Merged;
3475 state.status = RunStatus::Merged;
3476 state.merge = Some(MergeOutcome {
3477 mode: MergeMode::Pr,
3478 ok: true,
3479 detail: format!("gh {}", argv.join(" ")),
3480 empty: false,
3481 });
3482 if let Some(pr_record) = state.pr.as_mut() {
3487 pr_record.state = pr.state.as_str().to_owned();
3488 }
3489 state.event("land", format!("merged {} as `{subject}`", pr.url));
3490 announce_red_merge(state, &pr).await;
3491 state.save()?;
3492 write_pr_state_through(state, pr.state);
3493 return Ok(pr);
3494 }
3495 let after_seen = forge.view(&repo, pr_url).await.ok();
3496 let after = after_seen.as_ref().map(|s| s.pr.state);
3497 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3498 pr.state = PrLifecycle::Merged;
3499 state.status = RunStatus::Merged;
3500 state.merge = Some(outcome);
3501 if let Some(pr_record) = state.pr.as_mut() {
3502 pr_record.state = pr.state.as_str().to_owned();
3503 }
3504 state.event("land", format!("merged {} as `{subject}`", pr.url));
3505 announce_red_merge(state, &pr).await;
3506 state.save()?;
3507 write_pr_state_through(state, pr.state);
3508 return Ok(pr);
3509 }
3510 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3511 match verdict {
3512 Refused::Final => {
3513 let merge_state = after_seen
3514 .as_ref()
3515 .map(|s| s.merge_state.as_str())
3516 .filter(|m| !m.is_empty())
3517 .unwrap_or("unknown");
3518 let why = format!(
3522 "the merge was refused: {} (merge state: {merge_state})",
3523 out.1
3524 );
3525 stop(state, &repo, &pr, &why).await?;
3526 return Ok(pr);
3527 }
3528 verdict => {
3529 if waited >= WAIT_CEILING {
3534 let why = format!(
3535 "the merge was still refused after {} minutes: {}",
3536 WAIT_CEILING.as_secs() / 60,
3537 out.1
3538 );
3539 stop(state, &repo, &pr, &why).await?;
3540 return Ok(pr);
3541 }
3542 if verdict == Refused::Recheck {
3543 rechecked = true;
3544 }
3545 state.event(
3546 "land",
3547 "merge refused while the branch policy is not satisfied yet; waiting",
3548 );
3549 state.save()?;
3550 waited += POLL;
3551 forge.poll().await;
3552 }
3553 }
3554 }
3555 Step::Rebase => {
3556 if rebases >= budget {
3562 let why = format!(
3563 "the base moved under this branch {budget} time(s) and it still does \
3564 not merge; rebasing again would only race it"
3565 );
3566 stop(state, &repo, &pr, &why).await?;
3567 return Ok(pr);
3568 }
3569 rebases += 1;
3570 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3571 stop(
3572 state,
3573 &repo,
3574 &pr,
3575 "the pull request conflicts and this run has no winning branch to rebase",
3576 )
3577 .await?;
3578 return Ok(pr);
3579 };
3580 let base = state.base_branch.clone();
3581 state.event(
3582 "land",
3583 format!("{} no longer merges; rebasing onto {base}", pr.url),
3584 );
3585 state.save()?;
3586
3587 git::fetch(&repo, "origin", &base).await.ok();
3591 let scratch = state.dir().join("rebase");
3592 let onto = format!("origin/{base}");
3593 let rebased =
3594 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3595 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3596 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3597 Err(e) => Err(e),
3598 };
3599 match rebased {
3600 Ok(None) => {
3601 let pushed = {
3602 let merge_lock = repo_merge_lock(&repo);
3603 let _merge_slot = merge_lock.lock().await;
3604 git::push_rewritten(&repo, "origin", &branch).await?
3605 };
3606 if !pushed.ok() {
3607 let why = format!(
3608 "rebased {branch} but could not push it: {}",
3609 pushed.stderr.trim()
3610 );
3611 stop(state, &repo, &pr, &why).await?;
3612 return Ok(pr);
3613 }
3614 let head =
3617 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3618 Ok(head) => head,
3619 Err(e) => {
3620 let why = format!(
3621 "rebased and pushed {branch} but could not read the pushed \
3622 commit: {e:#}"
3623 );
3624 stop(state, &repo, &pr, &why).await?;
3625 return Ok(pr);
3626 }
3627 };
3628 crate::graph::refresh_reviewed_commits(state, &branch).await;
3629 awaiting_head = Some(head);
3630 rechecked = false;
3631 state.event("land", format!("rebased {branch} onto {base}"));
3632 state.save()?;
3633 waited = Duration::ZERO;
3636 tokio::time::sleep(POLL).await;
3637 }
3638 Ok(Some(conflict)) => {
3641 let why = format!(
3642 "{} conflicts with {base} and the rebase did not apply: {}",
3643 pr.url,
3644 conflict.chars().take(600).collect::<String>()
3645 );
3646 stop(state, &repo, &pr, &why).await?;
3647 return Ok(pr);
3648 }
3649 Err(e) => {
3650 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3651 stop(state, &repo, &pr, &why).await?;
3652 return Ok(pr);
3653 }
3654 }
3655 }
3656 Step::GiveUp { reason } => {
3657 stop(state, &repo, &pr, &reason).await?;
3658 return Ok(pr);
3659 }
3660 Step::Fix { reason } => {
3661 round += 1;
3662 waited = Duration::ZERO;
3663 for c in &pr.review_comments {
3664 shown.insert(c.body.clone());
3665 }
3666 state.event("land", format!("round {round}: {reason}"));
3667 state.save()?;
3668
3669 let logs = failing_logs(&repo, &seen.failing_urls).await;
3670 let was_red = pr.checks == Checks::Red;
3671 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3672 Fixed::Committed { head } => {
3673 awaiting_head = Some(head);
3676 rechecked = false;
3677 waited = Duration::ZERO;
3678 forge.poll().await;
3679 }
3680 Fixed::Declined if was_red => {
3681 let why = format!(
3682 "the fixer produced no commit while {} check(s) were failing \
3683 ({}); stopping instead of looping on an unchanged tree",
3684 pr.failing.len(),
3685 pr.failing.join(", ")
3686 );
3687 stop(state, &repo, &pr, &why).await?;
3688 return Ok(pr);
3689 }
3690 Fixed::Declined => state.event(
3695 "land",
3696 format!("round {round}: fixer declined the comments, nothing committed"),
3697 ),
3698 Fixed::Failed(why) => {
3699 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3700 return Ok(pr);
3701 }
3702 }
3703 state.save()?;
3704 }
3705 }
3706 }
3707}
3708
3709#[derive(Clone)]
3713struct Seen {
3714 pr: PrState,
3715 title: String,
3716 failing_urls: Vec<(String, String)>,
3717 head: String,
3719 rollup_head: String,
3722 merge_state: String,
3725 contexts: Vec<CheckInfo>,
3727 base: String,
3729}
3730
3731#[derive(Clone)]
3733struct CheckInfo {
3734 label: String,
3735 verdict: Verdict,
3736 required: Option<bool>,
3737}
3738
3739async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3743 let view = gh(
3744 repo,
3745 &[
3746 "pr".to_owned(),
3747 "view".to_owned(),
3748 pr_url.to_owned(),
3749 "--json".to_owned(),
3750 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3751 .to_owned(),
3752 ],
3753 )
3754 .await?;
3755 if !view.0 {
3756 bail!("gh pr view {pr_url}: {}", view.1);
3757 }
3758 let number = parse_pr(&view.1)?.number;
3759 let node = last_commit_node(repo, number).await;
3760 let mut seen = seen_from(&view.1, node.as_deref())?;
3761
3762 let inline = gh(
3763 repo,
3764 &[
3765 "api".to_owned(),
3766 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3767 ],
3768 )
3769 .await?;
3770 if inline.0 {
3771 match parse_inline_comments(&inline.1) {
3772 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3773 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3776 }
3777 } else {
3778 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3779 }
3780 Ok(seen)
3781}
3782
3783fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3794 let mut pr = parse_pr(view_json)?;
3795 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3796
3797 let mut rollup_head = String::new();
3798 let mut failing_urls = Vec::new();
3799 let mut contexts = Vec::new();
3800 let mut checks = Checks::Unknown;
3801 let mut failing = Vec::new();
3802 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3803 (checks, failing) = rollup_verdict(&rollup);
3804 failing_urls = rollup
3805 .iter()
3806 .filter(|c| c.verdict() == Verdict::Fail)
3807 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3808 .collect();
3809 contexts = rollup
3810 .iter()
3811 .map(|c| CheckInfo {
3812 label: c.label(),
3813 verdict: c.verdict(),
3814 required: c.is_required,
3815 })
3816 .collect();
3817 rollup_head = oid;
3818 }
3819 pr.checks = checks;
3820 pr.failing = failing;
3821
3822 Ok(Seen {
3823 pr,
3824 title: raw.title,
3825 failing_urls,
3826 head: raw.head_ref_oid,
3827 rollup_head,
3828 merge_state: raw.merge_state_status,
3829 contexts,
3830 base: raw.base_ref_name,
3831 })
3832}
3833
3834fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3837 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3838 if v.get("errors").is_some_and(|e| !e.is_null()) {
3839 return None;
3840 }
3841 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3842 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3843 let contexts = commit.pointer("/statusCheckRollup/contexts");
3844 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3845 return Some((oid.to_owned(), Vec::new()));
3847 };
3848 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3849 return None;
3850 }
3851 let nodes = contexts.get("nodes")?.as_array()?;
3852 let rollup = nodes
3853 .iter()
3854 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3855 .collect::<Result<Vec<_>, _>>()
3856 .ok()?;
3857 Some((oid.to_owned(), rollup))
3858}
3859
3860async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3863 let out = gh(
3864 repo,
3865 &[
3866 "api".to_owned(),
3867 "graphql".to_owned(),
3868 "-F".to_owned(),
3869 "owner={owner}".to_owned(),
3870 "-F".to_owned(),
3871 "repo={repo}".to_owned(),
3872 "-F".to_owned(),
3873 format!("number={number}"),
3874 "-f".to_owned(),
3875 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3876 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3877 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3878 ... on CheckRun{name status conclusion detailsUrl \
3879 isRequired(pullRequestNumber:$number)} \
3880 ... on StatusContext{context state targetUrl \
3881 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3882 .to_owned(),
3883 ],
3884 )
3885 .await
3886 .ok()?;
3887 out.0.then_some(out.1)
3888}
3889
3890#[doc(hidden)]
3892#[derive(Debug, PartialEq)]
3893pub enum Fixed {
3894 Committed {
3896 head: String,
3898 },
3899 Declined,
3901 Failed(String),
3903}
3904
3905#[doc(hidden)]
3911pub async fn fix_round(
3912 state: &mut RunState,
3913 pr: &PrState,
3914 round: usize,
3915 budget: usize,
3916 reason: &str,
3917 logs: &str,
3918) -> Result<Fixed> {
3919 let winner = state
3920 .winner()
3921 .cloned()
3922 .context("landing needs a winning candidate; none is recorded on this run")?;
3923 let roles = state
3924 .config
3925 .resolve_roles()
3926 .context("resolve the roster for the fix round")?;
3927 let attempts = crate::fixer::attempts(state, &roles, &winner);
3931 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
3932
3933 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3934 let artifacts = agent::artifacts_dir(&state.dir());
3935 let prompt = if state.config.cache_dir().is_some() {
3936 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3937 } else {
3938 prompt
3939 };
3940 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3944 let mut last = None;
3948 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
3949 let mut seat = seat_of(state, &seat_key, &spec.id);
3950 let stem = if i == 0 {
3951 format!("land-{round}")
3952 } else {
3953 format!("land-{round}-{}", spec.id)
3954 };
3955 let out = agent::invoke(
3956 &spec,
3957 &mut seat,
3958 &Invocation {
3959 cwd: &winner.worktree,
3960 prompt: &prompt,
3961 timeout: Duration::from_secs(state.config.graph.timeout_fix),
3962 allow_write: true,
3963 sessions: state.config.graph.sessions,
3964 artifacts: &artifacts,
3965 stem: &stem,
3966 run: &state.id,
3967 node: "land",
3968 cache_dir: state.config.cache_dir().as_deref(),
3969 attachments: &[],
3970 writable: &[],
3971 },
3972 )
3973 .await;
3974 state.seats.insert(seat.key.clone(), seat);
3975 if let Some(next) = ids.get(i + 1)
3976 && agent::chain_advances(&out)
3977 {
3978 let (class, why) = crate::fixer::failure_of(&out);
3979 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
3980 continue;
3981 }
3982 last = Some(out);
3983 break;
3984 }
3985 let out = last.expect("the fixer chain always has an entry");
3986
3987 match out {
3988 Ok(o) if o.quota_exhausted() => {
3989 return Ok(Fixed::Failed(
3990 "rate limited (quota); the fixer could not run".to_owned(),
3991 ));
3992 }
3993 Ok(o) if !o.usable() => {
3994 return Ok(Fixed::Failed(format!(
3995 "the fixer produced nothing usable (exit {:?}, timed out: {})",
3996 o.exit_code, o.timed_out
3997 )));
3998 }
3999 Ok(_) => {}
4000 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4001 }
4002
4003 if let Ok(r) = git::rescue_commit(
4006 &winner.worktree,
4007 &format!("magi: land round {round} fixes (uncommitted work)"),
4008 )
4009 .await
4010 {
4011 state.note_withheld("land", &r.withheld);
4012 }
4013 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4014 if after == before {
4015 return Ok(Fixed::Declined);
4016 }
4017
4018 let remote = state.config.merge.remote.clone();
4019 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4020 if !push.ok() {
4021 return Ok(Fixed::Failed(format!(
4022 "pushing {} to {remote} failed: {}",
4023 winner.branch, push.stderr
4024 )));
4025 }
4026 state.event(
4027 "land",
4028 format!("round {round}: pushed a fix to {}", winner.branch),
4029 );
4030 Ok(Fixed::Committed { head: after })
4031}
4032
4033pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4035 crate::fixer::seat_for(state, key, agent)
4036}
4037
4038fn fix_prompt(
4040 state: &RunState,
4041 pr: &PrState,
4042 round: usize,
4043 budget: usize,
4044 reason: &str,
4045 logs: &str,
4046) -> String {
4047 let mut s = format!(
4048 "Your patch is open as a pull request and it is not landing. Land round \
4049 {round} of {budget}.\n\n\
4050 Pull request: {}\n\n\
4051 What is holding it: {reason}\n\n\
4052 # The task\n\n{}\n",
4053 pr.url, state.instruction
4054 );
4055
4056 if pr.failing.is_empty() {
4057 s.push_str("\n# Failing checks\n\n(none)\n");
4058 } else {
4059 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4060 if logs.trim().is_empty() {
4061 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4062 } else {
4063 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4064 }
4065 }
4066
4067 if pr.review_comments.is_empty() {
4068 s.push_str("\n# Review comments\n\n(none)\n");
4069 } else {
4070 s.push_str("\n# Review comments\n");
4071 for c in &pr.review_comments {
4072 let where_ = match (&c.path, c.line) {
4073 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4074 (Some(p), None) => format!(" ({p})"),
4075 _ => String::new(),
4076 };
4077 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4078 }
4079 }
4080
4081 s.push_str(
4082 "\n# Rules\n\n\
4083 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4084 failing test; do not silence a lint with an allow attribute; do not \
4085 stretch a timeout to hide a race. If the check is right, the code is \
4086 wrong.\n\
4087 2. Change nothing the checks and the comments did not raise. A \
4088 drive-by refactor turns a one-line fix into a pull request that \
4089 needs reviewing again.\n\
4090 3. If a comment is wrong, say so with a checkable argument and change \
4091 nothing for it. A declined comment with a reason is a correct \
4092 outcome; a change made to appease a reviewer is not.\n\
4093 4. Commit in this worktree. magi pushes to the pull request's branch \
4094 for you; do not push, merge, or close anything yourself.\n\
4095 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4096 # Output\n\n\
4097 Say what you changed and why, and what you declined and why.",
4098 );
4099
4100 let language = &state.config.graph.language;
4101 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4102 let _ = write!(s, "\n\nWrite all prose in {language}.");
4103 }
4104 s.push_str(&crate::prompt::github_english(language));
4106 if let Some(overlay) = state.config.prompts.overlay("fix") {
4107 let _ = write!(s, "\n\n{overlay}");
4108 }
4109 s
4110}
4111
4112async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4115 let mut out = String::new();
4116 for (name, url) in failing.iter().take(MAX_LOGS) {
4117 let args = match (job_of(url), run_of(url)) {
4118 (Some(job), _) => vec![
4119 "run".to_owned(),
4120 "view".to_owned(),
4121 "--log-failed".to_owned(),
4122 "--job".to_owned(),
4123 job,
4124 ],
4125 (None, Some(run)) => vec![
4126 "run".to_owned(),
4127 "view".to_owned(),
4128 run,
4129 "--log-failed".to_owned(),
4130 ],
4131 (None, None) => continue,
4133 };
4134 let (ok, body) = match gh(repo, &args).await {
4135 Ok(v) => v,
4136 Err(e) => (false, format!("{e:#}")),
4137 };
4138 if !ok && body.trim().is_empty() {
4139 continue;
4140 }
4141 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4142 }
4143 out
4144}
4145
4146fn job_of(details_url: &str) -> Option<String> {
4149 let after = details_url.split("/job/").nth(1)?;
4150 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4151 (!id.is_empty()).then_some(id)
4152}
4153
4154pub(crate) fn run_of(details_url: &str) -> Option<String> {
4156 let after = details_url.split("/actions/runs/").nth(1)?;
4157 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4158 (!id.is_empty()).then_some(id)
4159}
4160
4161fn stop_comment(run_id: &str, why: &str) -> String {
4165 format!(
4166 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4167 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4168 )
4169}
4170
4171async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4176 let body = stop_comment(&state.id, why);
4177 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4178 if !body.contains(MARKER) {
4179 body = format!("{MARKER}\n{body}");
4180 }
4181 let posted = gh(
4182 repo,
4183 &[
4184 "pr".to_owned(),
4185 "comment".to_owned(),
4186 pr.number.to_string(),
4187 "--body".to_owned(),
4188 body,
4189 ],
4190 )
4191 .await;
4192 match posted {
4193 Ok((true, _)) => {}
4194 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4195 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4196 }
4197 state.status = RunStatus::Blocked;
4198 state.merge = Some(MergeOutcome {
4199 mode: MergeMode::Pr,
4200 ok: false,
4201 detail: why.to_owned(),
4202 empty: false,
4203 });
4204 state.event("land", format!("stopped: {why}"));
4205 state.save()?;
4206 Ok(())
4207}
4208
4209pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4225 let out = tokio::process::Command::new("gh")
4226 .args(args)
4227 .current_dir(cwd)
4228 .env_remove("GH_REPO")
4229 .quiet()
4230 .stdin(std::process::Stdio::null())
4231 .output()
4232 .await
4233 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4234 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4235 let err = String::from_utf8_lossy(&out.stderr);
4236 if body.trim().is_empty() {
4237 body = err.into_owned();
4238 } else if !err.trim().is_empty() {
4239 body.push_str(&err);
4240 }
4241 Ok((out.status.success(), body.trim().to_owned()))
4242}
4243
4244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4246pub(crate) enum Verdict {
4247 Pass,
4248 Fail,
4249 Pending,
4250 Unknown,
4251}
4252
4253#[derive(Debug, Deserialize)]
4254#[serde(rename_all = "camelCase")]
4255struct GhPr {
4256 #[serde(default)]
4257 url: String,
4258 #[serde(default)]
4259 number: u64,
4260 #[serde(default)]
4261 state: String,
4262 #[serde(default)]
4263 title: String,
4264 #[serde(default)]
4265 status_check_rollup: Vec<GhCheck>,
4266 #[serde(default)]
4273 merge_state_status: String,
4274 #[serde(default)]
4278 head_ref_oid: String,
4279 #[serde(default)]
4280 base_ref_name: String,
4281 #[serde(default)]
4282 reviews: Vec<GhReview>,
4283 #[serde(default)]
4284 comments: Vec<GhComment>,
4285}
4286
4287#[derive(Debug, Deserialize)]
4292#[serde(rename_all = "camelCase")]
4293struct GhCheck {
4294 #[serde(default)]
4295 name: Option<String>,
4296 #[serde(default)]
4297 context: Option<String>,
4298 #[serde(default)]
4299 status: Option<String>,
4300 #[serde(default)]
4301 conclusion: Option<String>,
4302 #[serde(default)]
4303 state: Option<String>,
4304 #[serde(default)]
4305 details_url: Option<String>,
4306 #[serde(default)]
4307 target_url: Option<String>,
4308 #[serde(default)]
4311 is_required: Option<bool>,
4312}
4313
4314impl GhCheck {
4315 fn label(&self) -> String {
4317 self.name
4318 .clone()
4319 .or_else(|| self.context.clone())
4320 .unwrap_or_else(|| "(unnamed check)".to_owned())
4321 }
4322
4323 fn url(&self) -> Option<&str> {
4325 self.details_url
4326 .as_deref()
4327 .or(self.target_url.as_deref())
4328 .filter(|u| !u.is_empty())
4329 }
4330
4331 fn verdict(&self) -> Verdict {
4339 if let Some(status) = self.status.as_deref() {
4340 if !status.eq_ignore_ascii_case("COMPLETED") {
4341 return Verdict::Pending;
4342 }
4343 }
4344 let outcome = self
4345 .conclusion
4346 .as_deref()
4347 .or(self.state.as_deref())
4348 .unwrap_or("");
4349 match outcome.to_ascii_uppercase().as_str() {
4350 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4351 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4352 | "ACTION_REQUIRED" => Verdict::Fail,
4353 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4354 Verdict::Pending
4355 }
4356 _ => Verdict::Unknown,
4357 }
4358 }
4359}
4360
4361#[derive(Debug, Deserialize)]
4362struct GhAuthor {
4363 #[serde(default)]
4364 login: String,
4365}
4366
4367#[derive(Debug, Deserialize)]
4368struct GhReview {
4369 #[serde(default)]
4370 author: GhAuthor,
4371 #[serde(default)]
4372 body: String,
4373}
4374
4375#[derive(Debug, Deserialize)]
4376struct GhComment {
4377 #[serde(default)]
4378 author: GhAuthor,
4379 #[serde(default)]
4380 body: String,
4381}
4382
4383#[derive(Debug, Deserialize)]
4384struct GhUser {
4385 #[serde(default)]
4386 login: String,
4387}
4388
4389#[derive(Debug, Deserialize)]
4390struct GhInline {
4391 #[serde(default)]
4392 user: GhUser,
4393 #[serde(default)]
4394 path: Option<String>,
4395 #[serde(default)]
4396 line: Option<u64>,
4397 #[serde(default)]
4398 body: String,
4399}
4400
4401impl Default for GhAuthor {
4402 fn default() -> Self {
4403 Self {
4404 login: "(unknown)".to_owned(),
4405 }
4406 }
4407}
4408
4409impl Default for GhUser {
4410 fn default() -> Self {
4411 Self {
4412 login: "(unknown)".to_owned(),
4413 }
4414 }
4415}
4416
4417#[cfg(test)]
4418mod tests {
4419 use super::*;
4420 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4421
4422 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4423 format!(
4424 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4425 )
4426 }
4427
4428 #[test]
4429 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4430 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4431 assert_eq!(
4432 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4433 Ok(())
4434 );
4435 for (json, why) in [
4436 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4437 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4438 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4439 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4440 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4441 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4442 ] {
4443 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4444 .unwrap_err()
4445 .why;
4446 assert!(err.contains(why), "{json}: {err}");
4447 }
4448 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4450 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4451 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4452 assert!(
4453 !closable(
4454 &head_json("x", "main", "OPEN", false),
4455 "magi/27b2/A",
4456 "main",
4457 &[]
4458 )
4459 .unwrap_err()
4460 .retry
4461 );
4462 assert!(is_forge_url("https://github.com/o/r.git"));
4463 assert!(is_forge_url("git@github.com:o/r.git"));
4464 assert!(!is_forge_url("/tmp/origin.git"));
4465 assert!(!is_forge_url("C:\\work\\origin.git"));
4466 assert!(!is_forge_url("file:///tmp/origin.git"));
4467 assert!(forge_unavailable(
4468 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4469 ));
4470 assert!(!forge_unavailable(
4471 "gh pr list failed: error connecting to api.github.com"
4472 ));
4473 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4474 assert!(
4476 closable(
4477 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4478 "b",
4479 "main",
4480 &["aaa".to_owned()]
4481 )
4482 .is_err()
4483 );
4484 }
4485
4486 #[test]
4487 fn the_close_comment_names_the_commit_on_the_base() {
4488 let e = crate::already::Evidence {
4489 proof: crate::already::Proof::PatchId,
4490 tip: "1234567890".to_owned(),
4491 commits: vec!["0e368de0000".to_owned()],
4492 };
4493 let c = superseded_comment("main", &e);
4494 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4495 }
4496
4497 const GREEN_OPEN: &str = r####"{
4499 "url": "https://github.com/yukimemi/magi/pull/10",
4500 "number": 10,
4501 "state": "OPEN",
4502 "mergeStateStatus": "CLEAN",
4503 "statusCheckRollup": [
4504 {
4505 "__typename": "CheckRun",
4506 "conclusion": "SKIPPED",
4507 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4508 "name": "review",
4509 "status": "COMPLETED",
4510 "workflowName": "claude-review"
4511 },
4512 {
4513 "__typename": "CheckRun",
4514 "conclusion": "SUCCESS",
4515 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4516 "name": "check (ubuntu-latest)",
4517 "status": "COMPLETED",
4518 "workflowName": "CI"
4519 },
4520 {
4521 "__typename": "CheckRun",
4522 "conclusion": "SUCCESS",
4523 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4524 "name": "rustfmt",
4525 "status": "COMPLETED",
4526 "workflowName": "CI"
4527 },
4528 {
4529 "__typename": "StatusContext",
4530 "context": "CodeRabbit",
4531 "state": "SUCCESS",
4532 "targetUrl": ""
4533 }
4534 ],
4535 "reviews": [],
4536 "comments": [
4537 {
4538 "author": {
4539 "login": "coderabbitai"
4540 },
4541 "authorAssociation": "NONE",
4542 "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"
4543 }
4544 ]
4545}"####;
4546
4547 const RED_OPEN: &str = r####"{
4549 "url": "https://github.com/yukimemi/magi/pull/9",
4550 "number": 9,
4551 "state": "OPEN",
4552 "mergeStateStatus": "UNSTABLE",
4553 "statusCheckRollup": [
4554 {
4555 "__typename": "CheckRun",
4556 "conclusion": "SUCCESS",
4557 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4558 "name": "check (ubuntu-latest)",
4559 "status": "COMPLETED",
4560 "workflowName": "CI"
4561 },
4562 {
4563 "__typename": "CheckRun",
4564 "conclusion": "SUCCESS",
4565 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4566 "name": "rustfmt",
4567 "status": "COMPLETED",
4568 "workflowName": "CI"
4569 },
4570 {
4571 "__typename": "CheckRun",
4572 "conclusion": "FAILURE",
4573 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4574 "name": "editorconfig",
4575 "status": "COMPLETED",
4576 "workflowName": "CI"
4577 },
4578 {
4579 "__typename": "StatusContext",
4580 "context": "CodeRabbit",
4581 "state": "SUCCESS",
4582 "targetUrl": ""
4583 }
4584 ],
4585 "reviews": [],
4586 "comments": [
4587 {
4588 "author": {
4589 "login": "coderabbitai"
4590 },
4591 "authorAssociation": "NONE",
4592 "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"
4593 }
4594 ]
4595}"####;
4596
4597 const PENDING_OPEN: &str = r####"{
4599 "url": "https://github.com/yukimemi/magi/pull/9",
4600 "number": 9,
4601 "state": "OPEN",
4602 "statusCheckRollup": [
4603 {
4604 "__typename": "CheckRun",
4605 "conclusion": "SUCCESS",
4606 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4607 "name": "check (ubuntu-latest)",
4608 "status": "COMPLETED",
4609 "workflowName": "CI"
4610 },
4611 {
4612 "__typename": "CheckRun",
4613 "conclusion": "SUCCESS",
4614 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4615 "name": "rustfmt",
4616 "status": "COMPLETED",
4617 "workflowName": "CI"
4618 },
4619 {
4620 "__typename": "CheckRun",
4621 "conclusion": null,
4622 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4623 "name": "editorconfig",
4624 "status": "IN_PROGRESS",
4625 "workflowName": "CI"
4626 },
4627 {
4628 "__typename": "StatusContext",
4629 "context": "CodeRabbit",
4630 "state": "SUCCESS",
4631 "targetUrl": ""
4632 }
4633 ],
4634 "reviews": [],
4635 "comments": []
4636}"####;
4637
4638 const MERGED: &str = r####"{
4640 "url": "https://github.com/yukimemi/magi/pull/16",
4641 "number": 16,
4642 "state": "MERGED",
4643 "statusCheckRollup": [
4644 {
4645 "__typename": "CheckRun",
4646 "conclusion": "SUCCESS",
4647 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4648 "name": "check (ubuntu-latest)",
4649 "status": "COMPLETED",
4650 "workflowName": "CI"
4651 },
4652 {
4653 "__typename": "CheckRun",
4654 "conclusion": "SUCCESS",
4655 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4656 "name": "review",
4657 "status": "COMPLETED",
4658 "workflowName": "claude-review"
4659 }
4660 ],
4661 "reviews": [],
4662 "comments": []
4663}"####;
4664
4665 const REVIEWED_OPEN: &str = r####"{
4667 "url": "https://github.com/yukimemi/magi/pull/12",
4668 "number": 12,
4669 "state": "OPEN",
4670 "statusCheckRollup": [
4671 {
4672 "__typename": "CheckRun",
4673 "conclusion": "SUCCESS",
4674 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4675 "name": "check (ubuntu-latest)",
4676 "status": "COMPLETED",
4677 "workflowName": "CI"
4678 },
4679 {
4680 "__typename": "CheckRun",
4681 "conclusion": "SUCCESS",
4682 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4683 "name": "review",
4684 "status": "COMPLETED",
4685 "workflowName": "claude-review"
4686 }
4687 ],
4688 "reviews": [
4689 {
4690 "author": {
4691 "login": "claude"
4692 },
4693 "state": "COMMENTED",
4694 "body": ""
4695 }
4696 ],
4697 "comments": [
4698 {
4699 "author": {
4700 "login": "coderabbitai"
4701 },
4702 "authorAssociation": "NONE",
4703 "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"
4704 },
4705 {
4706 "author": {
4707 "login": "claude"
4708 },
4709 "authorAssociation": "NONE",
4710 "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"
4711 }
4712 ]
4713}"####;
4714
4715 const INLINE: &str = r####"[
4717 {
4718 "user": {
4719 "login": "claude[bot]"
4720 },
4721 "path": "src/graph.rs",
4722 "line": 231,
4723 "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"
4724 }
4725]"####;
4726
4727 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4729<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4730
4731> [!IMPORTANT]
4732> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4733>
4734> This repository does not receive automatic reviews because it has fewer than 10 stars.
4735>
4736> <details>
4737> <summary>⚙️ Run configuration</summary>
4738>
4739> **Configuration used**: defaults
4740>
4741> **Review profile**: CHILL
4742>
4743> **Plan**: Team
4744>
4745> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4746>
4747> </details>
4748
4749<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4750
4751<!-- tips_start -->
4752
4753---
4754
4755Thanks 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.
4756
4757<details>
4758<summary>❤️ Share</summary>
4759
4760- [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"####;
4761
4762 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4764
4765---
4766### Reviewing PR #16
4767
4768- [x] Read AGENTS.md conventions
4769- [x] Review `src/daemon.rs` changes
4770- [x] Review `src/main.rs` changes (new `doctor` reporting)
4771- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4772- [x] Check test coverage for new behavior
4773- [x] Run verification commands (blocked — see note)
4774- [x] Post findings"####;
4775
4776 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4778
4779---
4780### Review: `magi review <branch>` — cheap-half-only graph
4781
4782Read 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.
4783
4784**Correctness**
4785
4786- 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"####;
4787
4788 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4789 PrState {
4790 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4791 number: 16,
4792 state: PrLifecycle::Open,
4793 checks,
4794 blocking: if matches!(checks, Checks::Red) {
4798 Blocking::Yes
4799 } else {
4800 Blocking::No
4801 },
4802 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4803 review_comments: (0..comments)
4804 .map(|i| ReviewComment {
4805 author: "coderabbitai".to_owned(),
4806 path: Some("src/graph.rs".to_owned()),
4807 line: Some(231),
4808 body: format!("finding {i}"),
4809 })
4810 .collect(),
4811 }
4812 }
4813
4814 #[test]
4815 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4816 use CiExpectation::{Absent, Expected};
4817 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4818 let p = pr(checks, &["x"], 0);
4819 for waited in [Duration::ZERO, CHECKS_GRACE] {
4820 assert_eq!(
4821 decide_with(&p, 0, 4, waited, Expected),
4822 decide(&p, 0, 4, waited)
4823 );
4824 }
4825 }
4826 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4828 let p = pr(checks, &["x"], 0);
4829 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4830 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4831 }
4832 let mut p = pr(Checks::Unknown, &[], 0);
4834 p.blocking = Blocking::Conflict;
4835 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4836 p.state = PrLifecycle::Merged;
4838 assert_eq!(
4839 decide_with(&p, 0, 4, Duration::ZERO, Absent),
4840 Step::Done { merged: true }
4841 );
4842 }
4843
4844 #[test]
4845 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4846 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4847 assert_eq!(state.number, 10);
4848 assert_eq!(state.state, PrLifecycle::Open);
4849 assert_eq!(state.checks, Checks::Green);
4850 assert!(state.failing.is_empty());
4851 assert!(
4852 state.review_comments.is_empty(),
4853 "the only comment is CodeRabbit's trigger notice: {:?}",
4854 state.review_comments
4855 );
4856 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4857 }
4858
4859 #[test]
4860 fn a_failing_check_parses_as_red_and_is_named() {
4861 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4862 assert_eq!(state.checks, Checks::Red);
4863 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4864 let mut blocking = state.clone();
4871 blocking.blocking = Blocking::Yes;
4872 match decide(&blocking, 0, 4, Duration::ZERO) {
4873 Step::Fix { reason } => {
4874 assert!(reason.contains("editorconfig"), "reason: {reason}");
4875 assert!(reason.contains("failing"), "reason: {reason}");
4876 }
4877 other => panic!("expected a fix round, got {other:?}"),
4878 }
4879 }
4880
4881 #[test]
4882 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4883 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4884 assert_eq!(state.checks, Checks::Pending);
4885 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4886 }
4887
4888 #[test]
4889 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4890 let state = parse_pr(MERGED).expect("merged fixture parses");
4891 assert_eq!(state.state, PrLifecycle::Merged);
4892 assert_eq!(
4893 decide(&state, 0, 4, Duration::ZERO),
4894 Step::Done { merged: true }
4895 );
4896 }
4897
4898 #[test]
4899 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4900 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4901 assert_eq!(state.checks, Checks::Green);
4902 let authors: Vec<&str> = state
4903 .review_comments
4904 .iter()
4905 .map(|c| c.author.as_str())
4906 .collect();
4907 assert_eq!(
4908 authors,
4909 vec!["claude"],
4910 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4911 );
4912 match decide(&state, 0, 4, Duration::ZERO) {
4913 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4914 other => panic!("expected a fix round, got {other:?}"),
4915 }
4916 }
4917
4918 #[test]
4919 fn inline_review_comments_keep_their_file_and_line() {
4920 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4921 assert_eq!(comments.len(), 1);
4922 assert_eq!(comments[0].author, "claude[bot]");
4923 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4924 assert_eq!(comments[0].line, Some(231));
4925 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4926 }
4927
4928 #[test]
4929 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4930 assert!(
4931 is_noise(CODERABBIT_TRIGGER),
4932 "CodeRabbit's trigger notice declares itself not a review"
4933 );
4934 assert!(
4935 is_noise(CLAUDE_CHECKLIST),
4936 "a progress checklist asks for nothing"
4937 );
4938 assert!(
4939 !is_noise(CLAUDE_FINDING),
4940 "a review that names a bug is input, not noise"
4941 );
4942
4943 let mut clean = pr(Checks::Green, &[], 0);
4944 clean.review_comments.push(ReviewComment {
4945 author: "coderabbitai".to_owned(),
4946 path: None,
4947 line: None,
4948 body: CODERABBIT_TRIGGER.to_owned(),
4949 });
4950 clean.review_comments.retain(|c| !is_noise(&c.body));
4951 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4952
4953 let mut found = pr(Checks::Green, &[], 0);
4954 found.review_comments.push(ReviewComment {
4955 author: "claude".to_owned(),
4956 path: None,
4957 line: None,
4958 body: CLAUDE_FINDING.to_owned(),
4959 });
4960 found.review_comments.retain(|c| !is_noise(&c.body));
4961 assert!(matches!(
4962 decide(&found, 0, 4, Duration::ZERO),
4963 Step::Fix { .. }
4964 ));
4965 }
4966
4967 #[test]
4968 fn the_policy_table_holds_for_every_combination_that_matters() {
4969 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4970 (
4971 "pending checks are waited for, even on the last round",
4972 pr(Checks::Pending, &[], 0),
4973 4,
4974 4,
4975 Duration::ZERO,
4976 Step::Wait,
4977 ),
4978 (
4979 "red checks are fixed",
4980 pr(Checks::Red, &["editorconfig"], 0),
4981 0,
4982 4,
4983 Duration::ZERO,
4984 Step::Fix {
4985 reason: "1 check(s) failing: editorconfig".to_owned(),
4986 },
4987 ),
4988 (
4989 "green with comments is fixed, not merged",
4990 pr(Checks::Green, &[], 2),
4991 1,
4992 4,
4993 Duration::ZERO,
4994 Step::Fix {
4995 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4996 .to_owned(),
4997 },
4998 ),
4999 (
5000 "green and clean merges",
5001 pr(Checks::Green, &[], 0),
5002 3,
5003 4,
5004 Duration::ZERO,
5005 Step::Merge,
5006 ),
5007 (
5008 "an unreadable rollup is waited on while the grace lasts",
5009 pr(Checks::Unknown, &[], 0),
5010 0,
5011 4,
5012 Duration::ZERO,
5013 Step::Wait,
5014 ),
5015 (
5016 "an unreadable rollup is never merged once the grace is spent",
5017 pr(Checks::Unknown, &[], 0),
5018 0,
5019 4,
5020 CHECKS_GRACE,
5021 Step::GiveUp {
5022 reason: "no check status is readable on the pull request after 3 minute(s); \
5023 refusing to merge on a guess"
5024 .to_owned(),
5025 },
5026 ),
5027 ];
5028 for (what, state, round, budget, waited, want) in cases {
5029 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5030 }
5031 }
5032
5033 #[test]
5034 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5035 let green = parse_pr(GREEN_OPEN).expect("parse");
5039 assert_eq!(green.blocking, Blocking::No);
5040 let red = parse_pr(RED_OPEN).expect("parse");
5041 assert_eq!(
5042 red.blocking,
5043 Blocking::No,
5044 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5045 );
5046 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5047 let quiet =
5049 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5050 assert_eq!(quiet.blocking, Blocking::Unsaid);
5051 }
5052
5053 #[test]
5054 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5055 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5061 nonblocking.blocking = Blocking::No;
5062 assert_eq!(
5063 decide(&nonblocking, 0, 4, Duration::ZERO),
5064 Step::Merge,
5065 "the forge says nothing is in the way, so nothing is"
5066 );
5067
5068 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5070 blocking.blocking = Blocking::Yes;
5071 assert!(matches!(
5072 decide(&blocking, 0, 4, Duration::ZERO),
5073 Step::Fix { .. }
5074 ));
5075
5076 let mut commented = pr(Checks::Red, &["coverage"], 1);
5079 commented.blocking = Blocking::No;
5080 assert!(matches!(
5081 decide(&commented, 0, 4, Duration::ZERO),
5082 Step::Fix { .. }
5083 ));
5084
5085 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5087 unsaid.blocking = Blocking::Unsaid;
5088 assert!(matches!(
5089 decide(&unsaid, 0, 4, Duration::ZERO),
5090 Step::Fix { .. }
5091 ));
5092 }
5093
5094 #[test]
5095 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5096 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5097 red.blocking = Blocking::No;
5098 assert_eq!(
5099 decide(&red, 0, 4, Duration::ZERO),
5100 Step::Merge,
5101 "announcing must not change the decision"
5102 );
5103 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5104 assert!(said.contains("yukimemi/magi"), "{said}");
5105 assert!(said.contains("#16"), "{said}");
5106 assert!(
5107 said.contains("https://github.com/yukimemi/magi/pull/16"),
5108 "{said}"
5109 );
5110 assert!(
5111 said.contains("test (windows-latest)") && said.contains("coverage"),
5112 "{said}"
5113 );
5114
5115 let green = pr(Checks::Green, &["stale"], 0);
5117 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5118 }
5119
5120 #[test]
5121 fn the_repo_label_comes_from_the_pull_request_url() {
5122 let p = Path::new("/tmp/checkout");
5123 assert_eq!(
5124 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5125 "yukimemi/magi"
5126 );
5127 assert_eq!(repo_label(p, "not a url"), "checkout");
5128 }
5129
5130 #[test]
5131 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5132 let mut conflicted = pr(Checks::Green, &[], 0);
5137 conflicted.blocking = Blocking::Conflict;
5138 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5139
5140 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5144 red.blocking = Blocking::Conflict;
5145 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5146
5147 let mut merged = pr(Checks::Red, &[], 0);
5149 merged.blocking = Blocking::Conflict;
5150 merged.state = PrLifecycle::Merged;
5151 assert_eq!(
5152 decide(&merged, 0, 4, Duration::ZERO),
5153 Step::Done { merged: true }
5154 );
5155 }
5156
5157 #[test]
5158 fn the_forge_verdict_is_read_off_merge_state_status() {
5159 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5162 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5163 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5164 }
5165 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5166 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5167 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5168 for quiet in ["", "UNKNOWN"] {
5171 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5172 assert!(Blocking::of(quiet).stops_a_merge());
5173 }
5174 }
5175
5176 #[test]
5177 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5178 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5179 let jj = "could not determine current branch: failed to run git: not on any branch";
5181
5182 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5183 .expect("the forge says merged, so it merged");
5184 assert!(landed.ok);
5185 assert!(
5186 landed.detail.contains("but the pull request is merged"),
5187 "the record must not read as a clean success: {}",
5188 landed.detail
5189 );
5190 assert!(
5191 landed.detail.contains("not on any branch"),
5192 "and it must keep what the command actually said: {}",
5193 landed.detail
5194 );
5195
5196 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5198 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5199 assert!(merged_after_all(&argv, jj, None).is_none());
5201 }
5202
5203 #[test]
5204 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5205 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5206 state.state = PrLifecycle::Closed;
5207 assert_eq!(
5208 decide(&state, 0, 4, Duration::ZERO),
5209 Step::Done { merged: false },
5210 "a human closing the pull request ends the loop, whatever CI says"
5211 );
5212 }
5213
5214 #[test]
5215 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5216 let red = decide(
5217 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5218 4,
5219 4,
5220 Duration::ZERO,
5221 );
5222 match red {
5223 Step::GiveUp { reason } => {
5224 assert!(reason.contains("editorconfig"), "reason: {reason}");
5225 assert!(reason.contains("test (macos)"), "reason: {reason}");
5226 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5227 }
5228 other => panic!("expected a give-up, got {other:?}"),
5229 }
5230
5231 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5232 match commented {
5233 Step::GiveUp { reason } => {
5234 assert!(reason.contains("unresolved"), "reason: {reason}");
5235 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5236 }
5237 other => panic!("expected a give-up, got {other:?}"),
5238 }
5239 }
5240
5241 #[test]
5242 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5243 let candidate_commit = "magi: candidate A (uncommitted work)";
5244 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5245 let argv = merge_argv(16, &subject);
5246
5247 assert!(argv.contains(&"--squash".to_owned()));
5248 assert!(argv.contains(&"--delete-branch".to_owned()));
5249 assert!(argv.contains(&"--subject".to_owned()));
5250 assert_eq!(
5251 argv.last().map(String::as_str),
5252 Some("add retries to the uploader"),
5253 "the subject must not be the candidate commit message"
5254 );
5255 assert_ne!(subject, candidate_commit);
5256 }
5257
5258 #[test]
5259 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5260 assert_eq!(
5261 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5262 "feat: a queue, an unattended loop, and a phone UI"
5263 );
5264 assert_eq!(
5265 merge_subject("", "# port the retry logic\n\ndetails"),
5266 "port the retry logic",
5267 "an empty title falls back to the task's first line, heading marks stripped"
5268 );
5269 }
5270
5271 #[test]
5272 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5273 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5274 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5275 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5276 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5277 assert_eq!(run_of(""), None);
5278 }
5279
5280 #[test]
5281 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5282 let mut out = Vec::new();
5283 push_if_outstanding(
5284 &mut out,
5285 ReviewComment {
5286 author: "yukimemi".to_owned(),
5287 path: None,
5288 line: None,
5289 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5290 },
5291 );
5292 assert!(out.is_empty());
5293 }
5294
5295 fn run_state() -> RunState {
5299 let mut state = RunState::new(
5300 std::path::PathBuf::from("/repo/magi"),
5301 "main".to_owned(),
5302 "abcdef1234".to_owned(),
5303 "add retries to the uploader".to_owned(),
5304 crate::config::Config::default(),
5305 );
5306 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5311 let nanos = std::time::SystemTime::now()
5312 .duration_since(std::time::UNIX_EPOCH)
5313 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5314 state.id = format!(
5315 "20261004-{nanos:06}-{:04x}",
5316 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5317 );
5318 state
5319 }
5320
5321 fn green_pr() -> PrState {
5322 PrState {
5323 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5324 number: 42,
5325 state: PrLifecycle::Open,
5326 checks: Checks::Green,
5327 blocking: Blocking::No,
5329 failing: Vec::new(),
5330 review_comments: vec![ReviewComment {
5331 author: "coderabbitai".to_owned(),
5332 path: Some("src/land.rs".to_owned()),
5333 line: Some(212),
5334 body: "this branch never checks the exit code".to_owned(),
5335 }],
5336 }
5337 }
5338
5339 #[test]
5340 fn github_facing_land_text_is_english_whatever_the_language() {
5341 let mut state = run_state();
5342 state.config.graph.language = "ja".to_owned();
5343 let comment = stop_comment(&state.id, "checks are still red");
5344 assert!(crate::github_text::check("", &comment).is_empty());
5345 assert!(comment.is_ascii(), "{comment}");
5346 assert!(comment.starts_with(MARKER));
5347
5348 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5349 let ja_at = p.find("Write all prose in ja").unwrap();
5350 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5351 assert!(ja_at < rule_at, "{p}");
5352 assert!(p.contains("stays in Japanese"), "{p}");
5353
5354 state.config.graph.language = "en".to_owned();
5355 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5356 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5357 assert!(!p.contains("does not apply"), "{p}");
5358 }
5359
5360 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5361
5362 fn panel() -> String {
5363 approval_panel(
5364 &run_state(),
5365 &green_pr(),
5366 NUMSTAT,
5367 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5368 &[
5369 "land: ask before merging".to_owned(),
5370 "land: colour the diff".to_owned(),
5371 ],
5372 "feat: merge approval from the phone",
5373 )
5374 }
5375
5376 #[test]
5377 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5378 let html = panel();
5379 for needle in [
5380 "42",
5381 "main",
5382 "src/land.rs",
5383 "src/web.rs",
5384 "assets/logo.png",
5385 "feat: merge approval from the phone",
5386 "land: ask before merging",
5387 "land: colour the diff",
5388 "coderabbitai",
5389 "this branch never checks the exit code",
5390 "green",
5391 ] {
5392 assert!(html.contains(needle), "the panel must state `{needle}`");
5393 }
5394 }
5395
5396 fn winning_candidate(summary: &str) -> Candidate {
5399 Candidate {
5400 index: 0,
5401 label: 'A',
5402 agent: "opus".to_owned(),
5403 branch: "magi/x/A".to_owned(),
5404 worktree: PathBuf::from("/wt/A"),
5405 summary: summary.to_owned(),
5406 stat: String::new(),
5407 files: 1,
5408 commits: 1,
5409 empty: false,
5410 failed: None,
5411 verified_noop: None,
5412 duration_ms: 0,
5413 folded: false,
5414 }
5415 }
5416
5417 fn uncontested_tally() -> Tally {
5418 Tally {
5419 first_choice: BTreeMap::from([('A', 1)]),
5420 borda: BTreeMap::new(),
5421 winner: 'A',
5422 rankings: 1,
5423 unanimous_initial: true,
5424 deliberated: false,
5425 changed_votes: 0,
5426 unanimous_final: true,
5427 tie_break: None,
5428 judges: 1,
5429 present: 1,
5430 quorum: 1,
5431 met_quorum: true,
5432 uncontested: None,
5433 }
5434 }
5435
5436 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5437 ReviewRecord {
5438 attempts: 0,
5439 reviewer,
5440 agent: agent.to_owned(),
5441 summary: summary.to_owned(),
5442 findings: Vec::new(),
5443 vote: None,
5444 failed: None,
5445 duration_ms: 0,
5446 }
5447 }
5448
5449 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5450 let answered = reviews.len();
5451 ReviewRound {
5452 round,
5453 head: "abc1234".to_owned(),
5454 verified_head: None,
5455 verified_at: None,
5456 reviews,
5457 e2e: Vec::new(),
5458 verify_retried: false,
5459 e2e_deferred: false,
5460 e2e_defer_reason: None,
5461 fix: None,
5462 blocking: 0,
5463 answered,
5464 expected: answered,
5465 clean: true,
5466 progressed: false,
5467 vote_split: false,
5468 reconsideration: Vec::new(),
5469 verdict: None,
5470 }
5471 }
5472
5473 #[test]
5474 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5475 let en = panel();
5476 assert!(en.contains("Task"), "{en}");
5477 assert!(en.contains("add retries to the uploader"), "{en}");
5478
5479 let mut state = run_state();
5480 state.config.graph.language = "ja".to_owned();
5481 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5482 assert!(ja.contains("タスク"), "{ja}");
5483 assert!(
5484 ja.contains("add retries to the uploader"),
5485 "the task itself is not translated: {ja}"
5486 );
5487 }
5488
5489 #[test]
5490 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5491 let html = panel();
5495 assert!(!html.contains("What changed"), "{html}");
5496 assert!(!html.contains("Review verdict"), "{html}");
5497 }
5498
5499 #[test]
5500 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5501 let mut state = run_state();
5502 state.candidates = vec![winning_candidate("")];
5503 state.tally = Some(uncontested_tally());
5504 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5505 assert!(
5506 !html.contains("What changed"),
5507 "an empty summary must not render an empty box: {html}"
5508 );
5509 }
5510
5511 #[test]
5512 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5513 let mut state = run_state();
5514 state.candidates = vec![winning_candidate(
5515 "Added a retry loop around the uploader PUT call.",
5516 )];
5517 state.tally = Some(uncontested_tally());
5518 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5519 assert!(en.contains("What changed"), "{en}");
5520 assert!(
5521 en.contains("Added a retry loop around the uploader PUT call."),
5522 "{en}"
5523 );
5524
5525 state.config.graph.language = "ja".to_owned();
5526 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5527 assert!(ja.contains("変更内容"), "{ja}");
5528 assert!(
5529 ja.contains("Added a retry loop around the uploader PUT call."),
5530 "{ja}"
5531 );
5532 }
5533
5534 #[test]
5535 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5536 let mut state = run_state();
5537 state.reviews = vec![
5538 review_round(
5539 1,
5540 vec![review_record(1, "alpha", "found a race, sent back")],
5541 ),
5542 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5543 ];
5544 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5545 assert!(en.contains("Review verdict"), "{en}");
5546 assert!(en.contains("race is fixed, clean"), "{en}");
5547 assert!(
5548 !en.contains("found a race, sent back"),
5549 "only the round that actually cleared the merge should show: {en}"
5550 );
5551
5552 state.config.graph.language = "ja".to_owned();
5553 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5554 assert!(ja.contains("レビューの結論"), "{ja}");
5555 assert!(ja.contains("レビュアー"), "{ja}");
5556 assert!(ja.contains("race is fixed, clean"), "{ja}");
5557 }
5558
5559 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5565 ReviewRecord {
5566 attempts: 0,
5567 reviewer,
5568 agent: agent.to_owned(),
5569 summary: String::new(),
5570 findings: Vec::new(),
5571 vote: None,
5572 failed: Some(reason.to_owned()),
5573 duration_ms: 0,
5574 }
5575 }
5576
5577 #[test]
5578 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5579 let mut state = run_state();
5580 state.reviews = vec![review_round(
5581 1,
5582 vec![
5583 review_record(1, "alpha", "clean, nothing to add"),
5584 unanswered_review_record(2, "beta", "timed out"),
5585 ],
5586 )];
5587 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5588 assert!(en.contains("clean, nothing to add"), "{en}");
5589 assert!(
5590 en.contains("produced no answer: timed out"),
5591 "a seat that never answered must say so, not render a blank box: {en}"
5592 );
5593 assert!(
5594 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5595 "no reviewer box may be left empty: {en}"
5596 );
5597
5598 state.config.graph.language = "ja".to_owned();
5599 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5600 assert!(ja.contains("回答なし: timed out"), "{ja}");
5601 }
5602
5603 #[test]
5604 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5605 let html = panel();
5606 assert!(!html.contains("<script"), "no script survives the csp");
5607 assert!(!html.contains("<form"), "form-action is 'none'");
5608 let pr = green_pr();
5609 assert_eq!(
5610 html.matches("http").count(),
5611 html.matches(pr.url.as_str()).count(),
5612 "the only http url in the panel is the pull request's own link"
5613 );
5614 }
5615
5616 #[test]
5617 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5618 let html = panel();
5619 assert!(
5620 html.contains(">+</span>"),
5621 "an added line carries a `+` in the gutter, not only a background"
5622 );
5623 assert!(
5624 html.contains(">-</span>"),
5625 "a removed line carries a `-` in the gutter, not only a background"
5626 );
5627 assert!(
5628 html.contains(">new line</span>"),
5629 "the marker is moved to the gutter, so the body is printed once without it"
5630 );
5631 }
5632
5633 #[test]
5634 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5635 let total = DIFF_MAX_LINES + 100;
5636 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5637 let html = approval_panel(
5638 &run_state(),
5639 &green_pr(),
5640 NUMSTAT,
5641 &diff,
5642 &[],
5643 "feat: something long",
5644 );
5645 assert!(
5646 html.contains(&format!("100 of {total} diff lines omitted")),
5647 "the note must say exactly how much was cut"
5648 );
5649 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5650 assert!(
5651 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5652 "nothing past the threshold is rendered"
5653 );
5654 assert!(
5655 html.contains("/repo/magi"),
5656 "the note says where the rest is"
5657 );
5658 }
5659
5660 #[test]
5661 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5662 let html = approval_panel(
5663 &run_state(),
5664 &green_pr(),
5665 "1\t2\tsrc/<b>&\"x\"'.rs",
5666 "",
5667 &[],
5668 "subject",
5669 );
5670 assert!(html.contains("src/<b>&"x"'.rs"));
5671 assert!(
5672 !html.contains("<b>"),
5673 "an agent-influenced path must never become markup"
5674 );
5675 }
5676
5677 #[tokio::test]
5678 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5679 let a = std::path::PathBuf::from("/repo/a");
5680 let b = std::path::PathBuf::from("/repo/b");
5681
5682 let held = repo_merge_lock(&a).lock_owned().await;
5683
5684 assert!(
5687 repo_merge_lock(&a).try_lock().is_err(),
5688 "a second merge into the same repository must not proceed concurrently"
5689 );
5690
5691 assert!(
5695 repo_merge_lock(&b).try_lock().is_ok(),
5696 "a different repository's merge lock must be independent"
5697 );
5698
5699 drop(held);
5700 assert!(
5701 repo_merge_lock(&a).try_lock().is_ok(),
5702 "the lock is released once the holder is done"
5703 );
5704 }
5705
5706 #[test]
5707 fn only_the_merge_choice_merges_and_silence_holds() {
5708 let table = [
5709 (None, Approval::Hold),
5710 (Some("merge"), Approval::Merge),
5711 (Some(" merge\n"), Approval::Merge),
5712 (Some("hold"), Approval::Hold),
5713 (Some(""), Approval::Hold),
5714 (Some("yes"), Approval::Hold),
5715 ];
5716 for (answer, want) in table {
5717 assert_eq!(
5718 approval(answer),
5719 want,
5720 "answer {answer:?} must resolve to {want:?}"
5721 );
5722 }
5723 }
5724
5725 #[tokio::test]
5726 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5727 let mut state = landing_state();
5728 state.config.graph.land_approval = true;
5729 let pr = green_pr();
5730
5731 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5732 .await
5733 .unwrap();
5734 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5735 assert!(
5736 !state.parked,
5737 "approval_gate itself never sets `parked`; only its caller does"
5738 );
5739
5740 let store = ask::Questions::open();
5741 let filed: Vec<_> = store
5742 .list()
5743 .into_iter()
5744 .filter(|q| q.run == state.id)
5745 .collect();
5746 assert_eq!(filed.len(), 1, "exactly one question is filed");
5747 assert_eq!(filed[0].node, APPROVAL_NODE);
5748 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5749 assert!(filed[0].status.open());
5750
5751 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5755 .await
5756 .unwrap();
5757 assert_eq!(again, ApprovalGate::Pending);
5758 let still_one = store
5759 .list()
5760 .into_iter()
5761 .filter(|q| q.run == state.id)
5762 .count();
5763 assert_eq!(
5764 still_one, 1,
5765 "asking twice must not double-file the question"
5766 );
5767 }
5768
5769 #[tokio::test]
5770 async fn approving_the_existing_question_is_read_back_as_approved() {
5771 crate::run::pin_test_home();
5772 let mut state = run_state();
5773 state.config.graph.land_approval = true;
5774 let pr = green_pr();
5775 assert_eq!(
5776 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5777 .await
5778 .unwrap(),
5779 ApprovalGate::Pending
5780 );
5781
5782 let store = ask::Questions::open();
5783 let mut q = store
5784 .list()
5785 .into_iter()
5786 .find(|q| q.run == state.id)
5787 .expect("filed above");
5788 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5789 store.put(&mut q).unwrap();
5790
5791 assert_eq!(
5792 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5793 .await
5794 .unwrap(),
5795 ApprovalGate::Approved
5796 );
5797 }
5798
5799 #[tokio::test]
5800 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5801 crate::run::pin_test_home();
5802 let store = ask::Questions::open();
5803
5804 let mut held_state = run_state();
5805 held_state.config.graph.land_approval = true;
5806 let pr = green_pr();
5807 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5808 .await
5809 .unwrap();
5810 let mut q = store
5811 .list()
5812 .into_iter()
5813 .find(|q| q.run == held_state.id)
5814 .expect("filed above");
5815 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5816 store.put(&mut q).unwrap();
5817 assert_eq!(
5818 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5819 .await
5820 .unwrap(),
5821 ApprovalGate::Held
5822 );
5823
5824 let mut abandoned_state = run_state();
5825 abandoned_state.config.graph.land_approval = true;
5826 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5827 .await
5828 .unwrap();
5829 let mut q = store
5830 .list()
5831 .into_iter()
5832 .find(|q| q.run == abandoned_state.id)
5833 .expect("filed above");
5834 q.abandon("no answer within the timeout");
5835 store.put(&mut q).unwrap();
5836 assert_eq!(
5837 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5838 .await
5839 .unwrap(),
5840 ApprovalGate::Held,
5841 "silence must never merge"
5842 );
5843 }
5844
5845 fn contested() -> ContestedHandoff {
5846 let finding = |id: &str, n: u32| crate::verdict::Finding {
5847 id: id.to_owned(),
5848 severity: crate::verdict::Severity::Major,
5849 file: Some("src/a.rs".to_owned()),
5850 line: Some(n),
5851 title: format!("problem {id}"),
5852 detail: String::new(),
5853 };
5854 ContestedHandoff {
5855 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5856 rejecters: vec![(1, "alpha".to_owned())],
5857 }
5858 }
5859
5860 #[test]
5861 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5862 let mut state = run_state();
5863 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5864 state.contested_handoff = Some(contested());
5865 assert!(contested_to_ask(&state).is_some());
5866 state.config.graph.hold_contested_merge = false;
5867 assert!(
5868 contested_to_ask(&state).is_none(),
5869 "the switch restores today"
5870 );
5871 }
5872
5873 #[test]
5874 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5875 let q = ask::Question::new(
5876 "run-1".to_owned(),
5877 APPROVAL_NODE.to_owned(),
5878 "land".to_owned(),
5879 "Merge?".to_owned(),
5880 String::new(),
5881 vec![APPROVE.to_owned(), HOLD.to_owned()],
5882 );
5883 let none = deputy_brief(&q, None);
5884 assert!(none.contains("could not be read"), "{none}");
5885 assert!(none.contains("Silence is a hold"), "{none}");
5886
5887 let mut state = run_state();
5888 state.pr = Some(crate::run::PrRecord {
5889 url: "https://example.test/pull/7".to_owned(),
5890 number: 7,
5891 state: "open".to_owned(),
5892 checks: "green".to_owned(),
5893 round: 0,
5894 rounds: 3,
5895 red_at_merge: Vec::new(),
5896 });
5897 state.contested_handoff = Some(contested());
5898 let b = deputy_brief(&q, Some(&state));
5899 assert!(b.contains("https://example.test/pull/7"), "{b}");
5900 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5901 assert!(b.contains("#1"), "the rejecting seat: {b}");
5902 state.contested_handoff = None;
5903 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5904 }
5905
5906 #[test]
5907 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5908 for lang in ["en", "ja"] {
5909 let mut cfg = crate::config::Config::default();
5910 cfg.graph.language = lang.to_owned();
5911 let w = words(&cfg.graph.language);
5912 let text = w.approval_detail(
5913 "https://github.com/yukimemi/magi/pull/42",
5914 "main",
5915 "feat: x",
5916 Some(&contested()),
5917 );
5918 assert!(text.contains("pull/42"), "{text}");
5919 assert!(
5920 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5921 "{text}"
5922 );
5923 assert!(text.contains("R3-1-5"), "{text}");
5924 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5925 assert!(text.contains("2"), "the rest are counted: {text}");
5926 assert!(text.contains("#1 (alpha)"), "{text}");
5927 }
5928 let plain = words("en").approval_detail("u", "main", "s", None);
5929 assert!(!plain.contains("reject"), "{plain}");
5930 }
5931
5932 #[tokio::test]
5933 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5934 crate::run::pin_test_home();
5935 let mut state = run_state();
5936 state.config.graph.land_approval = false;
5937 state.contested_handoff = Some(contested());
5938 let pr = green_pr();
5939 let c = contested_to_ask(&state);
5940 assert_eq!(
5941 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5942 .await
5943 .unwrap(),
5944 ApprovalGate::Pending,
5945 "silence is a hold"
5946 );
5947 let store = ask::Questions::open();
5948 let filed: Vec<_> = store
5949 .list()
5950 .into_iter()
5951 .filter(|q| q.run == state.id)
5952 .collect();
5953 assert_eq!(filed.len(), 1);
5954 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5955
5956 assert_eq!(
5957 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5958 .await
5959 .unwrap(),
5960 ApprovalGate::Pending
5961 );
5962 let mut q = store
5963 .list()
5964 .into_iter()
5965 .find(|q| q.run == state.id)
5966 .unwrap();
5967 assert_eq!(q.id, filed[0].id, "the same question after a resume");
5968 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5969 store.put(&mut q).unwrap();
5970 assert_eq!(
5971 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5972 .await
5973 .unwrap(),
5974 ApprovalGate::Approved
5975 );
5976 }
5977
5978 #[test]
5979 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5980 let rows = parse_numstat(NUMSTAT);
5981 assert_eq!(
5982 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5983 ["src/web.rs", "src/land.rs", "assets/logo.png"]
5984 );
5985 assert_eq!(rows[2].added, None, "a binary file has no line counts");
5986 }
5987 #[test]
5988 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5989 let mut state = run_state();
5993 state.config.graph.language = "ja".to_owned();
5994 let pr = green_pr();
5995 let commits = ["c1".to_owned()];
5996
5997 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5998 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
5999 assert!(ja.contains("squash されるコミット"), "{ja}");
6000 assert!(ja.contains("レビューコメント"), "{ja}");
6001 assert!(ja.contains("差分"), "{ja}");
6002 assert!(
6003 !ja.contains("Commits being squashed"),
6004 "no English left over"
6005 );
6006
6007 let w = words("ja");
6008 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6009 assert!(
6010 w.approval_detail("http://x/1", "main", "feat: x", None)
6011 .contains("パネル")
6012 );
6013
6014 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6016 assert!(ja.contains("feat: x"), "nor is the merge subject");
6017
6018 state.config.graph.language = "en".to_owned();
6021 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6022 assert!(en.contains("Commits being squashed"), "{en}");
6023 assert_eq!(words("Klingon").html_lang, "en");
6024 }
6025
6026 #[test]
6030 fn pick_open_pr_classifies_by_count_and_base() {
6031 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6032 assert_eq!(
6033 pick_open_pr(one, "main").unwrap(),
6034 OpenPr::One {
6035 url: "https://x/pull/58".into(),
6036 title: "t".into()
6037 }
6038 );
6039 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6040 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6041 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6042 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6043 assert_eq!(
6044 pick_open_pr(two, "main").unwrap(),
6045 OpenPr::Many(vec!["u1".into(), "u2".into()])
6046 );
6047 assert!(pick_open_pr("not json", "main").is_err());
6048 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6050 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6051 }
6052
6053 #[test]
6054 fn pick_merged_pr_picks_the_unique_match() {
6055 let json = r#"[
6056 {"url": "https://github.com/o/r/pull/42", "number": 42,
6057 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6058 ]"#;
6059 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6060 let found = pick_merged_pr(json, "main", created_at)
6061 .expect("valid json")
6062 .expect("one unambiguous match");
6063 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6064 assert_eq!(found.number, 42);
6065 }
6066
6067 #[test]
6071 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6072 let json = r#"[
6073 {"url": "https://github.com/o/r/pull/42", "number": 42,
6074 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6075 {"url": "https://github.com/o/r/pull/43", "number": 43,
6076 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6077 ]"#;
6078 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6079 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6080 }
6081
6082 #[test]
6086 fn pick_merged_pr_ignores_a_different_base_branch() {
6087 let json = r#"[
6088 {"url": "https://github.com/o/r/pull/42", "number": 42,
6089 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6090 ]"#;
6091 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6092 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6093 }
6094
6095 #[test]
6098 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6099 let json = r#"[
6100 {"url": "https://github.com/o/r/pull/42", "number": 42,
6101 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6102 ]"#;
6103 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6104 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6105 }
6106
6107 #[test]
6108 fn slug_of_pr_url_reads_host_owner_and_repo() {
6109 assert_eq!(
6110 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6111 Some("github.com/yukimemi/shun")
6112 );
6113 }
6114
6115 #[test]
6116 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6117 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6118 assert_eq!(slug_of_pr_url("not a url at all"), None);
6119 assert_eq!(slug_of_pr_url("https://github.com"), None);
6120 }
6121
6122 #[test]
6123 fn slug_of_repo_url_reads_host_owner_and_repo() {
6124 assert_eq!(
6125 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6126 Some("github.com/yukimemi/magi")
6127 );
6128 assert_eq!(slug_of_repo_url("https://github.com"), None);
6129 }
6130
6131 #[test]
6132 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6133 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6134 .expect("same repo, different case");
6135 }
6136
6137 #[test]
6142 fn ensure_same_repo_refuses_a_different_repo() {
6143 let err =
6144 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6145 let msg = format!("{err:#}");
6146 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6147 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6148 }
6149
6150 #[test]
6155 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6156 let err = ensure_same_repo(
6157 "github.com/yukimemi/magi",
6158 "github.example.com/yukimemi/magi",
6159 )
6160 .unwrap_err();
6161 let msg = format!("{err:#}");
6162 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6163 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6164 }
6165
6166 #[tokio::test]
6171 async fn find_external_merge_returns_none_without_a_winner() {
6172 let state = RunState::new(
6173 PathBuf::from("/no/such/repo"),
6174 "main".to_owned(),
6175 "0000000000000000000000000000000000000000".to_owned(),
6176 "irrelevant".to_owned(),
6177 crate::config::Config::default(),
6178 );
6179 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6180 }
6181
6182 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6183 let mut run = RunState::new(
6184 PathBuf::from(repo),
6185 "main".to_owned(),
6186 "abcdef1234".to_owned(),
6187 "x".to_owned(),
6188 crate::config::Config::default(),
6189 );
6190 run.id = id.to_owned();
6191 run.status = status;
6192 run.pr = Some(crate::run::PrRecord {
6193 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6194 url: url.to_owned(),
6195 state: state.to_owned(),
6196 checks: "red".to_owned(),
6197 round: 0,
6198 rounds: 2,
6199 red_at_merge: Vec::new(),
6200 });
6201 run.save_under(home).unwrap();
6202 }
6203
6204 fn recorded(home: &Path, id: &str) -> String {
6205 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6206 serde_json::from_str::<RunState>(&body)
6207 .unwrap()
6208 .pr
6209 .unwrap()
6210 .state
6211 }
6212
6213 const PR: &str = "https://github.com/o/r/pull/7";
6214
6215 #[test]
6216 fn write_through_updates_predecessors_and_siblings_only() {
6217 let tmp = tempfile::tempdir().unwrap();
6218 let h = tmp.path();
6219 pr_run(
6220 h,
6221 "20261004-100000-aaaa",
6222 "/repo/r",
6223 RunStatus::Superseded,
6224 PR,
6225 "open",
6226 );
6227 pr_run(
6228 h,
6229 "20261004-100100-bbbb",
6230 "/repo/r",
6231 RunStatus::Blocked,
6232 PR,
6233 "open",
6234 );
6235 pr_run(
6237 h,
6238 "20261004-100200-cccc",
6239 "/repo/r",
6240 RunStatus::Landing,
6241 PR,
6242 "open",
6243 );
6244 pr_run(
6246 h,
6247 "20261004-100300-dddd",
6248 "/repo/other",
6249 RunStatus::Blocked,
6250 "https://github.com/o/other/pull/7",
6251 "open",
6252 );
6253 pr_run(
6255 h,
6256 "20261004-100400-eeee",
6257 "/repo/r",
6258 RunStatus::Blocked,
6259 "https://github.com/o/r/pull/8",
6260 "open",
6261 );
6262 pr_run(
6263 h,
6264 "20261004-100500-ffff",
6265 "/repo/r",
6266 RunStatus::Merged,
6267 PR,
6268 "open",
6269 );
6270 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6271
6272 assert_eq!(
6273 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6274 2
6275 );
6276 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6277 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6278 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6279 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6280 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6281 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6283 assert_eq!(
6285 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6286 0
6287 );
6288 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6289 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6290 }
6291
6292 #[test]
6293 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6294 let tmp = tempfile::tempdir().unwrap();
6295 let h = tmp.path();
6296 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6297 pr_run(
6298 h,
6299 "20261004-100000-aaaa",
6300 "/repo/r",
6301 RunStatus::Superseded,
6302 &url(1),
6303 "open",
6304 );
6305 pr_run(
6306 h,
6307 "20261004-100100-bbbb",
6308 "/repo/r",
6309 RunStatus::Blocked,
6310 &url(2),
6311 "open",
6312 );
6313 pr_run(
6314 h,
6315 "20261004-100200-cccc",
6316 "/repo/r",
6317 RunStatus::Ready,
6318 &url(3),
6319 "open",
6320 );
6321 pr_run(
6322 h,
6323 "20261004-100300-dddd",
6324 "/repo/r",
6325 RunStatus::Ready,
6326 &url(4),
6327 "open",
6328 );
6329 pr_run(
6330 h,
6331 "20261004-100400-eeee",
6332 "/repo/r",
6333 RunStatus::Implementing,
6334 &url(1),
6335 "open",
6336 );
6337 assert_eq!(stale_open_prs(h).len(), 4);
6338
6339 let mut known = BTreeMap::new();
6340 known.insert(url(1), PrLifecycle::Merged);
6341 known.insert(url(2), PrLifecycle::Closed);
6342 known.insert(url(3), PrLifecycle::Open);
6343 assert_eq!(apply_pr_states(h, &known), 2);
6345 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6346 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6347 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6348 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6349 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6350 assert_eq!(apply_pr_states(h, &known), 0);
6351 }
6352
6353 use std::collections::VecDeque;
6356 use std::sync::Mutex;
6357
6358 struct Scripted {
6361 views: Mutex<VecDeque<Seen>>,
6362 merges: Mutex<VecDeque<(bool, String)>>,
6363 fix: Mutex<Option<Fixed>>,
6364 log: Mutex<Vec<&'static str>>,
6365 argvs: Mutex<Vec<Vec<String>>>,
6366 required: Mutex<Option<BTreeSet<String>>>,
6367 merged: Mutex<bool>,
6370 queued: Mutex<bool>,
6371 unreadable_after_merge: Mutex<bool>,
6373 }
6374
6375 impl Scripted {
6376 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6377 Self {
6378 views: Mutex::new(views.into()),
6379 merges: Mutex::new(
6380 merges
6381 .into_iter()
6382 .map(|(ok, m)| (ok, m.to_owned()))
6383 .collect(),
6384 ),
6385 fix: Mutex::new(None),
6386 log: Mutex::new(Vec::new()),
6387 argvs: Mutex::new(Vec::new()),
6388 required: Mutex::new(None),
6389 merged: Mutex::new(false),
6390 queued: Mutex::new(false),
6391 unreadable_after_merge: Mutex::new(false),
6392 }
6393 }
6394 fn argvs(&self) -> Vec<Vec<String>> {
6395 self.argvs.lock().unwrap().clone()
6396 }
6397 fn calls(&self) -> Vec<&'static str> {
6398 self.log.lock().unwrap().clone()
6399 }
6400 }
6401
6402 impl Forge for Scripted {
6403 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6404 self.log.lock().unwrap().push("view");
6405 if *self.unreadable_after_merge.lock().unwrap()
6406 && !self.argvs.lock().unwrap().is_empty()
6407 {
6408 anyhow::bail!("forge unreachable");
6409 }
6410 let mut v = self.views.lock().unwrap();
6411 let mut seen = if v.len() > 1 {
6412 v.pop_front().unwrap()
6413 } else {
6414 v[0].clone()
6415 };
6416 if *self.merged.lock().unwrap() {
6417 seen.pr.state = PrLifecycle::Merged;
6418 }
6419 Ok(seen)
6420 }
6421 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6422 self.log.lock().unwrap().push("merge");
6423 self.argvs.lock().unwrap().push(argv.to_vec());
6424 let out = self
6425 .merges
6426 .lock()
6427 .unwrap()
6428 .pop_front()
6429 .expect("unscripted merge");
6430 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6431 {
6432 *self.merged.lock().unwrap() = true;
6433 }
6434 Ok(out)
6435 }
6436 async fn poll(&self) {
6437 self.log.lock().unwrap().push("poll");
6438 }
6439 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6440 self.required.lock().unwrap().clone()
6441 }
6442 async fn fix(
6443 &self,
6444 _state: &mut RunState,
6445 _pr: &PrState,
6446 _round: usize,
6447 _budget: usize,
6448 _reason: &str,
6449 _logs: &str,
6450 ) -> Result<Fixed> {
6451 self.log.lock().unwrap().push("fix");
6452 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6453 }
6454 }
6455
6456 const REFUSED: &str =
6457 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6458
6459 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6460 let mut pr = green_pr();
6461 pr.checks = checks;
6462 pr.blocking = Blocking::of(merge_state);
6463 if !comments {
6464 pr.review_comments.clear();
6465 }
6466 Seen {
6467 pr,
6468 title: "feat: x".to_owned(),
6469 failing_urls: Vec::new(),
6470 head: head.to_owned(),
6471 rollup_head: head.to_owned(),
6472 merge_state: merge_state.to_owned(),
6473 contexts: Vec::new(),
6474 base: "main".to_owned(),
6475 }
6476 }
6477
6478 fn landing_state() -> RunState {
6479 crate::run::pin_test_home();
6480 let mut state = run_state();
6481 state.config.graph.land_approval = false;
6482 state
6483 }
6484
6485 #[test]
6486 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6487 assert!(!awaiting_new_head(None, "aaa"));
6488 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6489 assert!(awaiting_new_head(Some("abc123"), "def456"));
6490 assert!(awaiting_new_head(Some("abc123"), ""));
6491 }
6492
6493 #[test]
6494 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6495 let open = |c, m: &str| seen("a", c, m, false);
6496 let table = [
6497 (None, false, Refused::Pending),
6498 (
6499 Some(open(Checks::Pending, "BLOCKED")),
6500 false,
6501 Refused::Pending,
6502 ),
6503 (
6504 Some(open(Checks::Unknown, "BLOCKED")),
6505 false,
6506 Refused::Pending,
6507 ),
6508 (
6509 Some(open(Checks::Green, "UNKNOWN")),
6510 false,
6511 Refused::Pending,
6512 ),
6513 (Some(open(Checks::Green, "")), false, Refused::Pending),
6514 (
6515 Some(open(Checks::Green, "BLOCKED")),
6516 false,
6517 Refused::Recheck,
6518 ),
6519 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6520 ];
6521 for (after, rechecked, want) in table {
6522 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6523 }
6524 let mut closed = open(Checks::Green, "CLEAN");
6525 closed.pr.state = PrLifecycle::Closed;
6526 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6527 }
6528
6529 #[tokio::test]
6530 async fn a_normal_landing_merges_on_the_first_look() {
6531 let mut state = landing_state();
6532 let forge = Scripted::new(
6533 vec![seen("a", Checks::Green, "CLEAN", false)],
6534 vec![(true, "")],
6535 );
6536 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6537 .await
6538 .unwrap();
6539 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6541 assert_eq!(state.status, RunStatus::Merged);
6542 }
6543
6544 #[tokio::test]
6545 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6546 let mut state = landing_state();
6547 let forge = Scripted::new(
6548 vec![seen("a", Checks::Green, "CLEAN", false)],
6549 std::iter::repeat_n((true, ""), 100).collect(),
6550 );
6551 *forge.queued.lock().unwrap() = true;
6552 let task = async {
6553 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6554 .await
6555 .unwrap();
6556 };
6557 task.await;
6560 assert_ne!(state.status, RunStatus::Merged);
6561 }
6562
6563 #[tokio::test]
6564 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6565 let mut state = landing_state();
6566 let forge = Scripted::new(
6567 vec![seen("a", Checks::Green, "CLEAN", false)],
6568 std::iter::repeat_n((true, ""), 100).collect(),
6569 );
6570 *forge.unreadable_after_merge.lock().unwrap() = true;
6571 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6572 .await
6573 .ok();
6574 assert_ne!(state.status, RunStatus::Merged);
6575 }
6576
6577 #[tokio::test]
6578 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6579 let mut state = landing_state();
6580 let forge = Scripted::new(
6581 vec![
6582 seen("old", Checks::Green, "CLEAN", true),
6583 seen("old", Checks::Green, "CLEAN", true),
6585 seen("new", Checks::Pending, "BLOCKED", true),
6586 seen("new", Checks::Green, "CLEAN", true),
6587 ],
6588 vec![(true, "")],
6589 );
6590 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6591 head: "NEW".to_owned(),
6592 });
6593 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6594 .await
6595 .unwrap();
6596 assert_eq!(
6597 forge.calls(),
6598 [
6599 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6600 "view"
6601 ]
6602 );
6603 assert_eq!(state.status, RunStatus::Merged);
6604 }
6605
6606 #[tokio::test]
6607 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6608 let mut state = landing_state();
6609 let forge = Scripted::new(
6610 vec![
6611 seen("old", Checks::Green, "CLEAN", true),
6612 seen("someone-elses", Checks::Green, "CLEAN", true),
6613 ],
6614 vec![],
6615 );
6616 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6617 head: "mine".to_owned(),
6618 });
6619 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6620 .await
6621 .unwrap();
6622 assert!(!forge.calls().contains(&"merge"));
6623 let why = state.merge.as_ref().unwrap().detail.clone();
6624 assert!(
6625 why.contains("mine") && why.contains("someone-elses"),
6626 "{why}"
6627 );
6628 assert_eq!(state.status, RunStatus::Blocked);
6629 }
6630
6631 #[tokio::test]
6632 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6633 let mut state = landing_state();
6634 let forge = Scripted::new(
6635 vec![
6636 seen("a", Checks::Green, "CLEAN", false),
6637 seen("a", Checks::Green, "CLEAN", false),
6638 seen("a", Checks::Pending, "BLOCKED", false),
6639 seen("a", Checks::Pending, "BLOCKED", false),
6640 seen("a", Checks::Green, "CLEAN", false),
6641 ],
6642 vec![(false, REFUSED), (true, "")],
6643 );
6644 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6645 .await
6646 .unwrap();
6647 assert_eq!(
6648 forge.calls(),
6649 [
6650 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6651 "view"
6652 ]
6653 );
6654 assert_eq!(state.status, RunStatus::Merged);
6655 }
6656
6657 #[tokio::test]
6658 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6659 let mut state = landing_state();
6660 let forge = Scripted::new(
6661 vec![
6662 seen("a", Checks::Green, "CLEAN", false),
6663 seen("a", Checks::Green, "BLOCKED", false),
6664 ],
6665 vec![(false, REFUSED), (false, REFUSED)],
6666 );
6667 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6668 .await
6669 .unwrap();
6670 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6672 let why = state.merge.as_ref().unwrap().detail.clone();
6673 assert!(
6674 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6675 "{why}"
6676 );
6677 assert_eq!(state.status, RunStatus::Blocked);
6678 }
6679
6680 #[test]
6681 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6682 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6683 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6684 assert_eq!(bound_head("old", "old", Some("new")), None);
6686 assert_eq!(bound_head("new", "old", Some("new")), None);
6688 assert_eq!(bound_head("new", "old", None), None);
6689 assert_eq!(bound_head("", "", None), None);
6691 assert_eq!(bound_head("", "", Some("new")), None);
6692 assert_eq!(bound_head("abc", "", None), None);
6693 }
6694
6695 fn view_json(head: &str) -> String {
6696 format!(
6697 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6698 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6699 "reviews":[],"comments":[]}}"#
6700 )
6701 }
6702
6703 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6704 format!(
6705 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6706 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6707 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6708 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6709 )
6710 }
6711
6712 #[test]
6713 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6714 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6717 assert_eq!(s.rollup_head, "old");
6718 assert_eq!(s.pr.checks, Checks::Red);
6719 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6720 assert_eq!(s.failing_urls.len(), 1);
6721 }
6722
6723 #[test]
6724 fn checks_come_from_the_node_not_the_view() {
6725 let view = view_json("new").replace(
6726 r#""reviews""#,
6727 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6728 );
6729 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6730 assert_eq!(s.pr.checks, Checks::Green);
6731 assert!(s.pr.failing.is_empty());
6732 assert_eq!(
6733 bound_head(&s.head, &s.rollup_head, Some("new")),
6734 Some("new")
6735 );
6736 }
6737
6738 #[test]
6739 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6740 for node in [
6741 None,
6742 Some("not json".to_owned()),
6743 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6744 Some(node_json("new", "SUCCESS", true)),
6745 ] {
6746 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6747 assert!(s.rollup_head.is_empty());
6748 assert_eq!(s.pr.checks, Checks::Unknown);
6749 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6750 }
6751 }
6752
6753 #[test]
6754 fn the_merge_command_is_pinned_to_the_observed_head() {
6755 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6756 let at = argv
6757 .iter()
6758 .position(|a| a == "--match-head-commit")
6759 .unwrap();
6760 assert_eq!(argv[at + 1], "deadbeef");
6761 }
6762
6763 #[tokio::test]
6764 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6765 let mut state = landing_state();
6766 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6769 stale.rollup_head = "old".to_owned();
6770 let forge = Scripted::new(
6771 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6772 vec![],
6773 );
6774 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6775 head: "new".to_owned(),
6776 });
6777 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6778 .await
6779 .unwrap();
6780 assert!(!forge.calls().contains(&"merge"));
6781 assert_eq!(state.status, RunStatus::Blocked);
6782 let why = state.merge.as_ref().unwrap().detail.clone();
6783 assert!(why.contains("new") && why.contains("old"), "{why}");
6784 }
6785
6786 #[test]
6787 fn a_refusal_read_against_another_commits_checks_is_pending() {
6788 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6789 after.rollup_head = "old".to_owned();
6790 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6791 }
6792
6793 #[tokio::test]
6794 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6795 let mut state = landing_state();
6796 let forge = Scripted::new(
6797 vec![seen("a", Checks::Red, "CLEAN", false)],
6798 vec![(true, "")],
6799 );
6800 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6801 .await
6802 .unwrap();
6803 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6804 assert_eq!(state.status, RunStatus::Merged);
6805 }
6806
6807 #[tokio::test]
6808 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6809 let mut state = landing_state();
6810 let forge = Scripted::new(
6811 vec![
6812 seen("a", Checks::Green, "CLEAN", false),
6813 seen("a", Checks::Green, "CLEAN", false),
6814 seen("b", Checks::Green, "BLOCKED", false),
6816 seen("b", Checks::Green, "CLEAN", false),
6817 ],
6818 vec![(false, REFUSED), (true, "")],
6819 );
6820 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6821 .await
6822 .unwrap();
6823 assert_eq!(
6824 forge.calls(),
6825 [
6826 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6827 ]
6828 );
6829 assert_eq!(state.status, RunStatus::Merged);
6830 }
6831
6832 fn merged_view(head: &str) -> Seen {
6833 let mut m = seen(head, Checks::Green, "CLEAN", false);
6834 m.pr.state = PrLifecycle::Merged;
6835 m
6836 }
6837
6838 fn has(argv: &[String], flag: &str) -> bool {
6839 argv.iter().any(|a| a == flag)
6840 }
6841
6842 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6843 let at = argv.iter().position(|a| a == flag)?;
6844 argv.get(at + 1).map(String::as_str)
6845 }
6846
6847 const URL: &str = "https://github.com/o/r/pull/42";
6848
6849 #[tokio::test]
6850 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6851 let mut state = landing_state();
6852 let forge = Scripted::new(
6853 vec![
6854 seen("abc", Checks::Green, "CLEAN", false),
6855 seen("abc", Checks::Green, "CLEAN", false),
6856 ],
6857 vec![(true, "")],
6858 );
6859 land_with(&mut state, URL, &forge).await.unwrap();
6860 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6861 let argv = &forge.argvs()[0];
6862 assert!(has(argv, "--squash") && has(argv, "--subject"));
6863 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6864 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6865 assert_eq!(state.status, RunStatus::Merged);
6866 assert!(state.land_armed_head.is_none());
6867 }
6868
6869 #[tokio::test]
6870 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6871 let mut state = landing_state();
6872 state.land_armed_head = Some("a".to_owned());
6873 let forge = Scripted::new(
6874 vec![seen("a", Checks::Green, "CLEAN", false)],
6875 vec![(true, ""), (true, "")],
6876 );
6877 land_with(&mut state, URL, &forge).await.unwrap();
6878 let argvs = forge.argvs();
6879 assert!(has(&argvs[0], "--disable-auto"));
6880 assert!(!has(&argvs[1], "--auto"));
6881 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6882 assert_eq!(state.status, RunStatus::Merged);
6883 assert!(state.land_armed_head.is_none());
6884 }
6885
6886 #[tokio::test]
6887 async fn an_approval_never_carries_over_to_a_new_head() {
6888 crate::run::pin_test_home();
6889 let mut state = run_state();
6890 state.config.graph.land_approval = true;
6891 let pr = green_pr();
6892 let store = ask::Questions::open();
6893
6894 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6895 .await
6896 .unwrap();
6897 let mut q = store
6898 .list()
6899 .into_iter()
6900 .find(|q| q.run == state.id)
6901 .unwrap();
6902 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6903 store.put(&mut q).unwrap();
6904 assert_eq!(
6905 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6906 .await
6907 .unwrap(),
6908 ApprovalGate::Approved,
6909 "the same head keeps its approval"
6910 );
6911
6912 assert_eq!(
6914 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6915 .await
6916 .unwrap(),
6917 ApprovalGate::Pending
6918 );
6919 let all: Vec<_> = store
6920 .list()
6921 .into_iter()
6922 .filter(|q| q.run == state.id)
6923 .collect();
6924 assert_eq!(all.len(), 2);
6925
6926 state.land_approval = None;
6928 assert_eq!(
6929 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6930 .await
6931 .unwrap(),
6932 ApprovalGate::Pending
6933 );
6934 let open = store
6935 .list()
6936 .into_iter()
6937 .filter(|q| q.run == state.id && q.status.open())
6938 .count();
6939 assert_eq!(open, 1, "the superseded question was retired");
6940 }
6941
6942 #[tokio::test]
6943 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6944 let mut state = landing_state();
6945 let forge = Scripted::new(
6946 vec![
6947 seen("a", Checks::Green, "CLEAN", false),
6948 seen("b", Checks::Green, "CLEAN", false),
6950 ],
6951 vec![(true, "")],
6952 );
6953 land_with(&mut state, URL, &forge).await.unwrap();
6954 let argvs = forge.argvs();
6955 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6956 assert!(!has(&argvs[0], "--auto"));
6957 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6958 assert_eq!(state.status, RunStatus::Merged);
6959 }
6960
6961 fn passing(label: &str) -> CheckInfo {
6962 CheckInfo {
6963 label: label.to_owned(),
6964 verdict: Verdict::Pass,
6965 required: Some(false),
6966 }
6967 }
6968
6969 fn names(xs: &[&str]) -> BTreeSet<String> {
6970 xs.iter().map(|x| (*x).to_owned()).collect()
6971 }
6972
6973 #[test]
6974 fn a_required_check_the_rollup_never_listed_is_named() {
6975 let req = names(&["build"]);
6976 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6977 assert!(why.contains("never reported: build"), "{why}");
6978 assert!(!why.contains("probably waiting for a review"), "{why}");
6979 }
6980
6981 #[test]
6982 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6983 let why = waiting_on("BLOCKED", &[passing("review")], None);
6984 assert!(why.contains("could not be read"), "{why}");
6985 assert!(!why.contains("probably waiting for a review"), "{why}");
6986 }
6987
6988 #[test]
6989 fn all_required_reported_keeps_the_review_guess() {
6990 let req = names(&["build"]);
6991 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6992 assert!(why.contains("probably waiting for a review"), "{why}");
6993 assert!(!why.contains("never reported"), "{why}");
6994 }
6995
6996 #[test]
6997 fn required_names_match_the_rollup_ignoring_case_only() {
6998 let req = names(&["Build"]);
6999 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7000 assert!(!why.contains("never reported"), "{why}");
7001 }
7002
7003 #[test]
7004 fn required_contexts_are_read_from_protection_and_rulesets() {
7005 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7006 assert_eq!(
7007 parse_classic_required(classic),
7008 Some(names(&["build", "lint"]))
7009 );
7010 let rules = r#"[{"type":"pull_request","parameters":{}},
7011 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7012 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7013 assert_eq!(parse_ruleset_required("nope"), None);
7014 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7015 }
7016
7017 #[test]
7018 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7019 let shown = BTreeSet::new();
7020 let ok = seen("a", Checks::Green, "CLEAN", false);
7021 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7022 assert!(guard(Some(&ok)));
7023 assert!(!guard(None));
7024 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7025 let mut stale = ok.clone();
7026 stale.rollup_head = "old".to_owned();
7027 assert!(!guard(Some(&stale)));
7028 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7029 assert!(!guard(Some(&merged_view("a"))));
7030 }
7031
7032 #[test]
7033 fn the_rollup_node_carries_whether_each_check_is_required() {
7034 let node = node_json("new", "SUCCESS", false)
7035 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7036 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7037 assert_eq!(s.contexts.len(), 1);
7038 assert_eq!(s.contexts[0].required, Some(true));
7039 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7040 assert_eq!(s.contexts[0].required, None);
7041 }
7042
7043 #[tokio::test]
7044 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7045 let mut state = landing_state();
7046 state.land_armed_head = Some("a".to_owned());
7047 let forge = Scripted::new(
7048 vec![seen("a", Checks::Green, "CLEAN", true)],
7049 vec![(false, "disable exploded")],
7050 );
7051 land_with(&mut state, URL, &forge).await.unwrap();
7052 assert!(!forge.calls().contains(&"fix"));
7053 assert_eq!(state.status, RunStatus::Blocked);
7054 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7055 let why = state.merge.as_ref().unwrap().detail.clone();
7056 assert!(why.contains("disable exploded"), "{why}");
7057 }
7058}