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 unsandboxed: false,
3964 sessions: state.config.graph.sessions,
3965 artifacts: &artifacts,
3966 stem: &stem,
3967 run: &state.id,
3968 node: "land",
3969 cache_dir: state.config.cache_dir().as_deref(),
3970 attachments: &[],
3971 writable: &[],
3972 },
3973 )
3974 .await;
3975 state.seats.insert(seat.key.clone(), seat);
3976 if let Some(next) = ids.get(i + 1)
3977 && agent::chain_advances(&out)
3978 {
3979 let (class, why) = crate::fixer::failure_of(&out);
3980 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
3981 continue;
3982 }
3983 last = Some(out);
3984 break;
3985 }
3986 let out = last.expect("the fixer chain always has an entry");
3987
3988 match out {
3989 Ok(o) if o.quota_exhausted() => {
3990 return Ok(Fixed::Failed(
3991 "rate limited (quota); the fixer could not run".to_owned(),
3992 ));
3993 }
3994 Ok(o) if !o.usable() => {
3995 return Ok(Fixed::Failed(format!(
3996 "the fixer produced nothing usable (exit {:?}, timed out: {})",
3997 o.exit_code, o.timed_out
3998 )));
3999 }
4000 Ok(_) => {}
4001 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4002 }
4003
4004 if let Ok(r) = git::rescue_commit(
4007 &winner.worktree,
4008 &format!("magi: land round {round} fixes (uncommitted work)"),
4009 )
4010 .await
4011 {
4012 state.note_withheld("land", &r.withheld);
4013 }
4014 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4015 if after == before {
4016 return Ok(Fixed::Declined);
4017 }
4018
4019 let remote = state.config.merge.remote.clone();
4020 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
4021 if !push.ok() {
4022 return Ok(Fixed::Failed(format!(
4023 "pushing {} to {remote} failed: {}",
4024 winner.branch, push.stderr
4025 )));
4026 }
4027 state.event(
4028 "land",
4029 format!("round {round}: pushed a fix to {}", winner.branch),
4030 );
4031 Ok(Fixed::Committed { head: after })
4032}
4033
4034pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4036 crate::fixer::seat_for(state, key, agent)
4037}
4038
4039fn fix_prompt(
4041 state: &RunState,
4042 pr: &PrState,
4043 round: usize,
4044 budget: usize,
4045 reason: &str,
4046 logs: &str,
4047) -> String {
4048 let mut s = format!(
4049 "Your patch is open as a pull request and it is not landing. Land round \
4050 {round} of {budget}.\n\n\
4051 Pull request: {}\n\n\
4052 What is holding it: {reason}\n\n\
4053 # The task\n\n{}\n",
4054 pr.url, state.instruction
4055 );
4056
4057 if pr.failing.is_empty() {
4058 s.push_str("\n# Failing checks\n\n(none)\n");
4059 } else {
4060 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4061 if logs.trim().is_empty() {
4062 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4063 } else {
4064 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4065 }
4066 }
4067
4068 if pr.review_comments.is_empty() {
4069 s.push_str("\n# Review comments\n\n(none)\n");
4070 } else {
4071 s.push_str("\n# Review comments\n");
4072 for c in &pr.review_comments {
4073 let where_ = match (&c.path, c.line) {
4074 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4075 (Some(p), None) => format!(" ({p})"),
4076 _ => String::new(),
4077 };
4078 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4079 }
4080 }
4081
4082 s.push_str(
4083 "\n# Rules\n\n\
4084 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4085 failing test; do not silence a lint with an allow attribute; do not \
4086 stretch a timeout to hide a race. If the check is right, the code is \
4087 wrong.\n\
4088 2. Change nothing the checks and the comments did not raise. A \
4089 drive-by refactor turns a one-line fix into a pull request that \
4090 needs reviewing again.\n\
4091 3. If a comment is wrong, say so with a checkable argument and change \
4092 nothing for it. A declined comment with a reason is a correct \
4093 outcome; a change made to appease a reviewer is not.\n\
4094 4. Commit in this worktree. magi pushes to the pull request's branch \
4095 for you; do not push, merge, or close anything yourself.\n\
4096 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4097 # Output\n\n\
4098 Say what you changed and why, and what you declined and why.",
4099 );
4100
4101 let language = &state.config.graph.language;
4102 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4103 let _ = write!(s, "\n\nWrite all prose in {language}.");
4104 }
4105 s.push_str(&crate::prompt::github_english(language));
4107 if let Some(overlay) = state.config.prompts.overlay("fix") {
4108 let _ = write!(s, "\n\n{overlay}");
4109 }
4110 s
4111}
4112
4113async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4116 let mut out = String::new();
4117 for (name, url) in failing.iter().take(MAX_LOGS) {
4118 let args = match (job_of(url), run_of(url)) {
4119 (Some(job), _) => vec![
4120 "run".to_owned(),
4121 "view".to_owned(),
4122 "--log-failed".to_owned(),
4123 "--job".to_owned(),
4124 job,
4125 ],
4126 (None, Some(run)) => vec![
4127 "run".to_owned(),
4128 "view".to_owned(),
4129 run,
4130 "--log-failed".to_owned(),
4131 ],
4132 (None, None) => continue,
4134 };
4135 let (ok, body) = match gh(repo, &args).await {
4136 Ok(v) => v,
4137 Err(e) => (false, format!("{e:#}")),
4138 };
4139 if !ok && body.trim().is_empty() {
4140 continue;
4141 }
4142 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4143 }
4144 out
4145}
4146
4147fn job_of(details_url: &str) -> Option<String> {
4150 let after = details_url.split("/job/").nth(1)?;
4151 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4152 (!id.is_empty()).then_some(id)
4153}
4154
4155pub(crate) fn run_of(details_url: &str) -> Option<String> {
4157 let after = details_url.split("/actions/runs/").nth(1)?;
4158 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4159 (!id.is_empty()).then_some(id)
4160}
4161
4162fn stop_comment(run_id: &str, why: &str) -> String {
4166 format!(
4167 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4168 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4169 )
4170}
4171
4172async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4177 let body = stop_comment(&state.id, why);
4178 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4179 if !body.contains(MARKER) {
4180 body = format!("{MARKER}\n{body}");
4181 }
4182 let posted = gh(
4183 repo,
4184 &[
4185 "pr".to_owned(),
4186 "comment".to_owned(),
4187 pr.number.to_string(),
4188 "--body".to_owned(),
4189 body,
4190 ],
4191 )
4192 .await;
4193 match posted {
4194 Ok((true, _)) => {}
4195 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4196 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4197 }
4198 state.status = RunStatus::Blocked;
4199 state.merge = Some(MergeOutcome {
4200 mode: MergeMode::Pr,
4201 ok: false,
4202 detail: why.to_owned(),
4203 empty: false,
4204 });
4205 state.event("land", format!("stopped: {why}"));
4206 state.save()?;
4207 Ok(())
4208}
4209
4210pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4226 let out = tokio::process::Command::new("gh")
4227 .args(args)
4228 .current_dir(cwd)
4229 .env_remove("GH_REPO")
4230 .quiet()
4231 .stdin(std::process::Stdio::null())
4232 .output()
4233 .await
4234 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4235 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4236 let err = String::from_utf8_lossy(&out.stderr);
4237 if body.trim().is_empty() {
4238 body = err.into_owned();
4239 } else if !err.trim().is_empty() {
4240 body.push_str(&err);
4241 }
4242 Ok((out.status.success(), body.trim().to_owned()))
4243}
4244
4245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4247pub(crate) enum Verdict {
4248 Pass,
4249 Fail,
4250 Pending,
4251 Unknown,
4252}
4253
4254#[derive(Debug, Deserialize)]
4255#[serde(rename_all = "camelCase")]
4256struct GhPr {
4257 #[serde(default)]
4258 url: String,
4259 #[serde(default)]
4260 number: u64,
4261 #[serde(default)]
4262 state: String,
4263 #[serde(default)]
4264 title: String,
4265 #[serde(default)]
4266 status_check_rollup: Vec<GhCheck>,
4267 #[serde(default)]
4274 merge_state_status: String,
4275 #[serde(default)]
4279 head_ref_oid: String,
4280 #[serde(default)]
4281 base_ref_name: String,
4282 #[serde(default)]
4283 reviews: Vec<GhReview>,
4284 #[serde(default)]
4285 comments: Vec<GhComment>,
4286}
4287
4288#[derive(Debug, Deserialize)]
4293#[serde(rename_all = "camelCase")]
4294struct GhCheck {
4295 #[serde(default)]
4296 name: Option<String>,
4297 #[serde(default)]
4298 context: Option<String>,
4299 #[serde(default)]
4300 status: Option<String>,
4301 #[serde(default)]
4302 conclusion: Option<String>,
4303 #[serde(default)]
4304 state: Option<String>,
4305 #[serde(default)]
4306 details_url: Option<String>,
4307 #[serde(default)]
4308 target_url: Option<String>,
4309 #[serde(default)]
4312 is_required: Option<bool>,
4313}
4314
4315impl GhCheck {
4316 fn label(&self) -> String {
4318 self.name
4319 .clone()
4320 .or_else(|| self.context.clone())
4321 .unwrap_or_else(|| "(unnamed check)".to_owned())
4322 }
4323
4324 fn url(&self) -> Option<&str> {
4326 self.details_url
4327 .as_deref()
4328 .or(self.target_url.as_deref())
4329 .filter(|u| !u.is_empty())
4330 }
4331
4332 fn verdict(&self) -> Verdict {
4340 if let Some(status) = self.status.as_deref() {
4341 if !status.eq_ignore_ascii_case("COMPLETED") {
4342 return Verdict::Pending;
4343 }
4344 }
4345 let outcome = self
4346 .conclusion
4347 .as_deref()
4348 .or(self.state.as_deref())
4349 .unwrap_or("");
4350 match outcome.to_ascii_uppercase().as_str() {
4351 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4352 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4353 | "ACTION_REQUIRED" => Verdict::Fail,
4354 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4355 Verdict::Pending
4356 }
4357 _ => Verdict::Unknown,
4358 }
4359 }
4360}
4361
4362#[derive(Debug, Deserialize)]
4363struct GhAuthor {
4364 #[serde(default)]
4365 login: String,
4366}
4367
4368#[derive(Debug, Deserialize)]
4369struct GhReview {
4370 #[serde(default)]
4371 author: GhAuthor,
4372 #[serde(default)]
4373 body: String,
4374}
4375
4376#[derive(Debug, Deserialize)]
4377struct GhComment {
4378 #[serde(default)]
4379 author: GhAuthor,
4380 #[serde(default)]
4381 body: String,
4382}
4383
4384#[derive(Debug, Deserialize)]
4385struct GhUser {
4386 #[serde(default)]
4387 login: String,
4388}
4389
4390#[derive(Debug, Deserialize)]
4391struct GhInline {
4392 #[serde(default)]
4393 user: GhUser,
4394 #[serde(default)]
4395 path: Option<String>,
4396 #[serde(default)]
4397 line: Option<u64>,
4398 #[serde(default)]
4399 body: String,
4400}
4401
4402impl Default for GhAuthor {
4403 fn default() -> Self {
4404 Self {
4405 login: "(unknown)".to_owned(),
4406 }
4407 }
4408}
4409
4410impl Default for GhUser {
4411 fn default() -> Self {
4412 Self {
4413 login: "(unknown)".to_owned(),
4414 }
4415 }
4416}
4417
4418#[cfg(test)]
4419mod tests {
4420 use super::*;
4421 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4422
4423 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4424 format!(
4425 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4426 )
4427 }
4428
4429 #[test]
4430 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4431 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4432 assert_eq!(
4433 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4434 Ok(())
4435 );
4436 for (json, why) in [
4437 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4438 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4439 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4440 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4441 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4442 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4443 ] {
4444 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4445 .unwrap_err()
4446 .why;
4447 assert!(err.contains(why), "{json}: {err}");
4448 }
4449 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4451 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4452 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4453 assert!(
4454 !closable(
4455 &head_json("x", "main", "OPEN", false),
4456 "magi/27b2/A",
4457 "main",
4458 &[]
4459 )
4460 .unwrap_err()
4461 .retry
4462 );
4463 assert!(is_forge_url("https://github.com/o/r.git"));
4464 assert!(is_forge_url("git@github.com:o/r.git"));
4465 assert!(!is_forge_url("/tmp/origin.git"));
4466 assert!(!is_forge_url("C:\\work\\origin.git"));
4467 assert!(!is_forge_url("file:///tmp/origin.git"));
4468 assert!(forge_unavailable(
4469 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4470 ));
4471 assert!(!forge_unavailable(
4472 "gh pr list failed: error connecting to api.github.com"
4473 ));
4474 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4475 assert!(
4477 closable(
4478 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4479 "b",
4480 "main",
4481 &["aaa".to_owned()]
4482 )
4483 .is_err()
4484 );
4485 }
4486
4487 #[test]
4488 fn the_close_comment_names_the_commit_on_the_base() {
4489 let e = crate::already::Evidence {
4490 proof: crate::already::Proof::PatchId,
4491 tip: "1234567890".to_owned(),
4492 commits: vec!["0e368de0000".to_owned()],
4493 };
4494 let c = superseded_comment("main", &e);
4495 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4496 }
4497
4498 const GREEN_OPEN: &str = r####"{
4500 "url": "https://github.com/yukimemi/magi/pull/10",
4501 "number": 10,
4502 "state": "OPEN",
4503 "mergeStateStatus": "CLEAN",
4504 "statusCheckRollup": [
4505 {
4506 "__typename": "CheckRun",
4507 "conclusion": "SKIPPED",
4508 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4509 "name": "review",
4510 "status": "COMPLETED",
4511 "workflowName": "claude-review"
4512 },
4513 {
4514 "__typename": "CheckRun",
4515 "conclusion": "SUCCESS",
4516 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4517 "name": "check (ubuntu-latest)",
4518 "status": "COMPLETED",
4519 "workflowName": "CI"
4520 },
4521 {
4522 "__typename": "CheckRun",
4523 "conclusion": "SUCCESS",
4524 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4525 "name": "rustfmt",
4526 "status": "COMPLETED",
4527 "workflowName": "CI"
4528 },
4529 {
4530 "__typename": "StatusContext",
4531 "context": "CodeRabbit",
4532 "state": "SUCCESS",
4533 "targetUrl": ""
4534 }
4535 ],
4536 "reviews": [],
4537 "comments": [
4538 {
4539 "author": {
4540 "login": "coderabbitai"
4541 },
4542 "authorAssociation": "NONE",
4543 "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"
4544 }
4545 ]
4546}"####;
4547
4548 const RED_OPEN: &str = r####"{
4550 "url": "https://github.com/yukimemi/magi/pull/9",
4551 "number": 9,
4552 "state": "OPEN",
4553 "mergeStateStatus": "UNSTABLE",
4554 "statusCheckRollup": [
4555 {
4556 "__typename": "CheckRun",
4557 "conclusion": "SUCCESS",
4558 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4559 "name": "check (ubuntu-latest)",
4560 "status": "COMPLETED",
4561 "workflowName": "CI"
4562 },
4563 {
4564 "__typename": "CheckRun",
4565 "conclusion": "SUCCESS",
4566 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4567 "name": "rustfmt",
4568 "status": "COMPLETED",
4569 "workflowName": "CI"
4570 },
4571 {
4572 "__typename": "CheckRun",
4573 "conclusion": "FAILURE",
4574 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4575 "name": "editorconfig",
4576 "status": "COMPLETED",
4577 "workflowName": "CI"
4578 },
4579 {
4580 "__typename": "StatusContext",
4581 "context": "CodeRabbit",
4582 "state": "SUCCESS",
4583 "targetUrl": ""
4584 }
4585 ],
4586 "reviews": [],
4587 "comments": [
4588 {
4589 "author": {
4590 "login": "coderabbitai"
4591 },
4592 "authorAssociation": "NONE",
4593 "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"
4594 }
4595 ]
4596}"####;
4597
4598 const PENDING_OPEN: &str = r####"{
4600 "url": "https://github.com/yukimemi/magi/pull/9",
4601 "number": 9,
4602 "state": "OPEN",
4603 "statusCheckRollup": [
4604 {
4605 "__typename": "CheckRun",
4606 "conclusion": "SUCCESS",
4607 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4608 "name": "check (ubuntu-latest)",
4609 "status": "COMPLETED",
4610 "workflowName": "CI"
4611 },
4612 {
4613 "__typename": "CheckRun",
4614 "conclusion": "SUCCESS",
4615 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4616 "name": "rustfmt",
4617 "status": "COMPLETED",
4618 "workflowName": "CI"
4619 },
4620 {
4621 "__typename": "CheckRun",
4622 "conclusion": null,
4623 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4624 "name": "editorconfig",
4625 "status": "IN_PROGRESS",
4626 "workflowName": "CI"
4627 },
4628 {
4629 "__typename": "StatusContext",
4630 "context": "CodeRabbit",
4631 "state": "SUCCESS",
4632 "targetUrl": ""
4633 }
4634 ],
4635 "reviews": [],
4636 "comments": []
4637}"####;
4638
4639 const MERGED: &str = r####"{
4641 "url": "https://github.com/yukimemi/magi/pull/16",
4642 "number": 16,
4643 "state": "MERGED",
4644 "statusCheckRollup": [
4645 {
4646 "__typename": "CheckRun",
4647 "conclusion": "SUCCESS",
4648 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4649 "name": "check (ubuntu-latest)",
4650 "status": "COMPLETED",
4651 "workflowName": "CI"
4652 },
4653 {
4654 "__typename": "CheckRun",
4655 "conclusion": "SUCCESS",
4656 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4657 "name": "review",
4658 "status": "COMPLETED",
4659 "workflowName": "claude-review"
4660 }
4661 ],
4662 "reviews": [],
4663 "comments": []
4664}"####;
4665
4666 const REVIEWED_OPEN: &str = r####"{
4668 "url": "https://github.com/yukimemi/magi/pull/12",
4669 "number": 12,
4670 "state": "OPEN",
4671 "statusCheckRollup": [
4672 {
4673 "__typename": "CheckRun",
4674 "conclusion": "SUCCESS",
4675 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4676 "name": "check (ubuntu-latest)",
4677 "status": "COMPLETED",
4678 "workflowName": "CI"
4679 },
4680 {
4681 "__typename": "CheckRun",
4682 "conclusion": "SUCCESS",
4683 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4684 "name": "review",
4685 "status": "COMPLETED",
4686 "workflowName": "claude-review"
4687 }
4688 ],
4689 "reviews": [
4690 {
4691 "author": {
4692 "login": "claude"
4693 },
4694 "state": "COMMENTED",
4695 "body": ""
4696 }
4697 ],
4698 "comments": [
4699 {
4700 "author": {
4701 "login": "coderabbitai"
4702 },
4703 "authorAssociation": "NONE",
4704 "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"
4705 },
4706 {
4707 "author": {
4708 "login": "claude"
4709 },
4710 "authorAssociation": "NONE",
4711 "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"
4712 }
4713 ]
4714}"####;
4715
4716 const INLINE: &str = r####"[
4718 {
4719 "user": {
4720 "login": "claude[bot]"
4721 },
4722 "path": "src/graph.rs",
4723 "line": 231,
4724 "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"
4725 }
4726]"####;
4727
4728 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4730<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4731
4732> [!IMPORTANT]
4733> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4734>
4735> This repository does not receive automatic reviews because it has fewer than 10 stars.
4736>
4737> <details>
4738> <summary>⚙️ Run configuration</summary>
4739>
4740> **Configuration used**: defaults
4741>
4742> **Review profile**: CHILL
4743>
4744> **Plan**: Team
4745>
4746> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4747>
4748> </details>
4749
4750<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4751
4752<!-- tips_start -->
4753
4754---
4755
4756Thanks 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.
4757
4758<details>
4759<summary>❤️ Share</summary>
4760
4761- [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"####;
4762
4763 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4765
4766---
4767### Reviewing PR #16
4768
4769- [x] Read AGENTS.md conventions
4770- [x] Review `src/daemon.rs` changes
4771- [x] Review `src/main.rs` changes (new `doctor` reporting)
4772- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4773- [x] Check test coverage for new behavior
4774- [x] Run verification commands (blocked — see note)
4775- [x] Post findings"####;
4776
4777 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4779
4780---
4781### Review: `magi review <branch>` — cheap-half-only graph
4782
4783Read 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.
4784
4785**Correctness**
4786
4787- 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"####;
4788
4789 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4790 PrState {
4791 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4792 number: 16,
4793 state: PrLifecycle::Open,
4794 checks,
4795 blocking: if matches!(checks, Checks::Red) {
4799 Blocking::Yes
4800 } else {
4801 Blocking::No
4802 },
4803 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4804 review_comments: (0..comments)
4805 .map(|i| ReviewComment {
4806 author: "coderabbitai".to_owned(),
4807 path: Some("src/graph.rs".to_owned()),
4808 line: Some(231),
4809 body: format!("finding {i}"),
4810 })
4811 .collect(),
4812 }
4813 }
4814
4815 #[test]
4816 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
4817 use CiExpectation::{Absent, Expected};
4818 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
4819 let p = pr(checks, &["x"], 0);
4820 for waited in [Duration::ZERO, CHECKS_GRACE] {
4821 assert_eq!(
4822 decide_with(&p, 0, 4, waited, Expected),
4823 decide(&p, 0, 4, waited)
4824 );
4825 }
4826 }
4827 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
4829 let p = pr(checks, &["x"], 0);
4830 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
4831 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
4832 }
4833 let mut p = pr(Checks::Unknown, &[], 0);
4835 p.blocking = Blocking::Conflict;
4836 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
4837 p.state = PrLifecycle::Merged;
4839 assert_eq!(
4840 decide_with(&p, 0, 4, Duration::ZERO, Absent),
4841 Step::Done { merged: true }
4842 );
4843 }
4844
4845 #[test]
4846 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4847 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4848 assert_eq!(state.number, 10);
4849 assert_eq!(state.state, PrLifecycle::Open);
4850 assert_eq!(state.checks, Checks::Green);
4851 assert!(state.failing.is_empty());
4852 assert!(
4853 state.review_comments.is_empty(),
4854 "the only comment is CodeRabbit's trigger notice: {:?}",
4855 state.review_comments
4856 );
4857 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4858 }
4859
4860 #[test]
4861 fn a_failing_check_parses_as_red_and_is_named() {
4862 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4863 assert_eq!(state.checks, Checks::Red);
4864 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4865 let mut blocking = state.clone();
4872 blocking.blocking = Blocking::Yes;
4873 match decide(&blocking, 0, 4, Duration::ZERO) {
4874 Step::Fix { reason } => {
4875 assert!(reason.contains("editorconfig"), "reason: {reason}");
4876 assert!(reason.contains("failing"), "reason: {reason}");
4877 }
4878 other => panic!("expected a fix round, got {other:?}"),
4879 }
4880 }
4881
4882 #[test]
4883 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4884 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4885 assert_eq!(state.checks, Checks::Pending);
4886 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4887 }
4888
4889 #[test]
4890 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4891 let state = parse_pr(MERGED).expect("merged fixture parses");
4892 assert_eq!(state.state, PrLifecycle::Merged);
4893 assert_eq!(
4894 decide(&state, 0, 4, Duration::ZERO),
4895 Step::Done { merged: true }
4896 );
4897 }
4898
4899 #[test]
4900 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4901 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4902 assert_eq!(state.checks, Checks::Green);
4903 let authors: Vec<&str> = state
4904 .review_comments
4905 .iter()
4906 .map(|c| c.author.as_str())
4907 .collect();
4908 assert_eq!(
4909 authors,
4910 vec!["claude"],
4911 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4912 );
4913 match decide(&state, 0, 4, Duration::ZERO) {
4914 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4915 other => panic!("expected a fix round, got {other:?}"),
4916 }
4917 }
4918
4919 #[test]
4920 fn inline_review_comments_keep_their_file_and_line() {
4921 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4922 assert_eq!(comments.len(), 1);
4923 assert_eq!(comments[0].author, "claude[bot]");
4924 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4925 assert_eq!(comments[0].line, Some(231));
4926 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4927 }
4928
4929 #[test]
4930 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4931 assert!(
4932 is_noise(CODERABBIT_TRIGGER),
4933 "CodeRabbit's trigger notice declares itself not a review"
4934 );
4935 assert!(
4936 is_noise(CLAUDE_CHECKLIST),
4937 "a progress checklist asks for nothing"
4938 );
4939 assert!(
4940 !is_noise(CLAUDE_FINDING),
4941 "a review that names a bug is input, not noise"
4942 );
4943
4944 let mut clean = pr(Checks::Green, &[], 0);
4945 clean.review_comments.push(ReviewComment {
4946 author: "coderabbitai".to_owned(),
4947 path: None,
4948 line: None,
4949 body: CODERABBIT_TRIGGER.to_owned(),
4950 });
4951 clean.review_comments.retain(|c| !is_noise(&c.body));
4952 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4953
4954 let mut found = pr(Checks::Green, &[], 0);
4955 found.review_comments.push(ReviewComment {
4956 author: "claude".to_owned(),
4957 path: None,
4958 line: None,
4959 body: CLAUDE_FINDING.to_owned(),
4960 });
4961 found.review_comments.retain(|c| !is_noise(&c.body));
4962 assert!(matches!(
4963 decide(&found, 0, 4, Duration::ZERO),
4964 Step::Fix { .. }
4965 ));
4966 }
4967
4968 #[test]
4969 fn the_policy_table_holds_for_every_combination_that_matters() {
4970 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4971 (
4972 "pending checks are waited for, even on the last round",
4973 pr(Checks::Pending, &[], 0),
4974 4,
4975 4,
4976 Duration::ZERO,
4977 Step::Wait,
4978 ),
4979 (
4980 "red checks are fixed",
4981 pr(Checks::Red, &["editorconfig"], 0),
4982 0,
4983 4,
4984 Duration::ZERO,
4985 Step::Fix {
4986 reason: "1 check(s) failing: editorconfig".to_owned(),
4987 },
4988 ),
4989 (
4990 "green with comments is fixed, not merged",
4991 pr(Checks::Green, &[], 2),
4992 1,
4993 4,
4994 Duration::ZERO,
4995 Step::Fix {
4996 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4997 .to_owned(),
4998 },
4999 ),
5000 (
5001 "green and clean merges",
5002 pr(Checks::Green, &[], 0),
5003 3,
5004 4,
5005 Duration::ZERO,
5006 Step::Merge,
5007 ),
5008 (
5009 "an unreadable rollup is waited on while the grace lasts",
5010 pr(Checks::Unknown, &[], 0),
5011 0,
5012 4,
5013 Duration::ZERO,
5014 Step::Wait,
5015 ),
5016 (
5017 "an unreadable rollup is never merged once the grace is spent",
5018 pr(Checks::Unknown, &[], 0),
5019 0,
5020 4,
5021 CHECKS_GRACE,
5022 Step::GiveUp {
5023 reason: "no check status is readable on the pull request after 3 minute(s); \
5024 refusing to merge on a guess"
5025 .to_owned(),
5026 },
5027 ),
5028 ];
5029 for (what, state, round, budget, waited, want) in cases {
5030 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5031 }
5032 }
5033
5034 #[test]
5035 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5036 let green = parse_pr(GREEN_OPEN).expect("parse");
5040 assert_eq!(green.blocking, Blocking::No);
5041 let red = parse_pr(RED_OPEN).expect("parse");
5042 assert_eq!(
5043 red.blocking,
5044 Blocking::No,
5045 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5046 );
5047 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5048 let quiet =
5050 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5051 assert_eq!(quiet.blocking, Blocking::Unsaid);
5052 }
5053
5054 #[test]
5055 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5056 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5062 nonblocking.blocking = Blocking::No;
5063 assert_eq!(
5064 decide(&nonblocking, 0, 4, Duration::ZERO),
5065 Step::Merge,
5066 "the forge says nothing is in the way, so nothing is"
5067 );
5068
5069 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5071 blocking.blocking = Blocking::Yes;
5072 assert!(matches!(
5073 decide(&blocking, 0, 4, Duration::ZERO),
5074 Step::Fix { .. }
5075 ));
5076
5077 let mut commented = pr(Checks::Red, &["coverage"], 1);
5080 commented.blocking = Blocking::No;
5081 assert!(matches!(
5082 decide(&commented, 0, 4, Duration::ZERO),
5083 Step::Fix { .. }
5084 ));
5085
5086 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5088 unsaid.blocking = Blocking::Unsaid;
5089 assert!(matches!(
5090 decide(&unsaid, 0, 4, Duration::ZERO),
5091 Step::Fix { .. }
5092 ));
5093 }
5094
5095 #[test]
5096 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5097 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5098 red.blocking = Blocking::No;
5099 assert_eq!(
5100 decide(&red, 0, 4, Duration::ZERO),
5101 Step::Merge,
5102 "announcing must not change the decision"
5103 );
5104 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5105 assert!(said.contains("yukimemi/magi"), "{said}");
5106 assert!(said.contains("#16"), "{said}");
5107 assert!(
5108 said.contains("https://github.com/yukimemi/magi/pull/16"),
5109 "{said}"
5110 );
5111 assert!(
5112 said.contains("test (windows-latest)") && said.contains("coverage"),
5113 "{said}"
5114 );
5115
5116 let green = pr(Checks::Green, &["stale"], 0);
5118 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5119 }
5120
5121 #[test]
5122 fn the_repo_label_comes_from_the_pull_request_url() {
5123 let p = Path::new("/tmp/checkout");
5124 assert_eq!(
5125 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5126 "yukimemi/magi"
5127 );
5128 assert_eq!(repo_label(p, "not a url"), "checkout");
5129 }
5130
5131 #[test]
5132 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5133 let mut conflicted = pr(Checks::Green, &[], 0);
5138 conflicted.blocking = Blocking::Conflict;
5139 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5140
5141 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5145 red.blocking = Blocking::Conflict;
5146 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5147
5148 let mut merged = pr(Checks::Red, &[], 0);
5150 merged.blocking = Blocking::Conflict;
5151 merged.state = PrLifecycle::Merged;
5152 assert_eq!(
5153 decide(&merged, 0, 4, Duration::ZERO),
5154 Step::Done { merged: true }
5155 );
5156 }
5157
5158 #[test]
5159 fn the_forge_verdict_is_read_off_merge_state_status() {
5160 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5163 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5164 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5165 }
5166 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5167 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5168 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5169 for quiet in ["", "UNKNOWN"] {
5172 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5173 assert!(Blocking::of(quiet).stops_a_merge());
5174 }
5175 }
5176
5177 #[test]
5178 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5179 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5180 let jj = "could not determine current branch: failed to run git: not on any branch";
5182
5183 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5184 .expect("the forge says merged, so it merged");
5185 assert!(landed.ok);
5186 assert!(
5187 landed.detail.contains("but the pull request is merged"),
5188 "the record must not read as a clean success: {}",
5189 landed.detail
5190 );
5191 assert!(
5192 landed.detail.contains("not on any branch"),
5193 "and it must keep what the command actually said: {}",
5194 landed.detail
5195 );
5196
5197 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5199 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5200 assert!(merged_after_all(&argv, jj, None).is_none());
5202 }
5203
5204 #[test]
5205 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5206 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5207 state.state = PrLifecycle::Closed;
5208 assert_eq!(
5209 decide(&state, 0, 4, Duration::ZERO),
5210 Step::Done { merged: false },
5211 "a human closing the pull request ends the loop, whatever CI says"
5212 );
5213 }
5214
5215 #[test]
5216 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5217 let red = decide(
5218 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5219 4,
5220 4,
5221 Duration::ZERO,
5222 );
5223 match red {
5224 Step::GiveUp { reason } => {
5225 assert!(reason.contains("editorconfig"), "reason: {reason}");
5226 assert!(reason.contains("test (macos)"), "reason: {reason}");
5227 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5228 }
5229 other => panic!("expected a give-up, got {other:?}"),
5230 }
5231
5232 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5233 match commented {
5234 Step::GiveUp { reason } => {
5235 assert!(reason.contains("unresolved"), "reason: {reason}");
5236 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5237 }
5238 other => panic!("expected a give-up, got {other:?}"),
5239 }
5240 }
5241
5242 #[test]
5243 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5244 let candidate_commit = "magi: candidate A (uncommitted work)";
5245 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5246 let argv = merge_argv(16, &subject);
5247
5248 assert!(argv.contains(&"--squash".to_owned()));
5249 assert!(argv.contains(&"--delete-branch".to_owned()));
5250 assert!(argv.contains(&"--subject".to_owned()));
5251 assert_eq!(
5252 argv.last().map(String::as_str),
5253 Some("add retries to the uploader"),
5254 "the subject must not be the candidate commit message"
5255 );
5256 assert_ne!(subject, candidate_commit);
5257 }
5258
5259 #[test]
5260 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5261 assert_eq!(
5262 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5263 "feat: a queue, an unattended loop, and a phone UI"
5264 );
5265 assert_eq!(
5266 merge_subject("", "# port the retry logic\n\ndetails"),
5267 "port the retry logic",
5268 "an empty title falls back to the task's first line, heading marks stripped"
5269 );
5270 }
5271
5272 #[test]
5273 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5274 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5275 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5276 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5277 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5278 assert_eq!(run_of(""), None);
5279 }
5280
5281 #[test]
5282 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5283 let mut out = Vec::new();
5284 push_if_outstanding(
5285 &mut out,
5286 ReviewComment {
5287 author: "yukimemi".to_owned(),
5288 path: None,
5289 line: None,
5290 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5291 },
5292 );
5293 assert!(out.is_empty());
5294 }
5295
5296 fn run_state() -> RunState {
5300 let mut state = RunState::new(
5301 std::path::PathBuf::from("/repo/magi"),
5302 "main".to_owned(),
5303 "abcdef1234".to_owned(),
5304 "add retries to the uploader".to_owned(),
5305 crate::config::Config::default(),
5306 );
5307 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5312 let nanos = std::time::SystemTime::now()
5313 .duration_since(std::time::UNIX_EPOCH)
5314 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5315 state.id = format!(
5316 "20261004-{nanos:06}-{:04x}",
5317 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5318 );
5319 state
5320 }
5321
5322 fn green_pr() -> PrState {
5323 PrState {
5324 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5325 number: 42,
5326 state: PrLifecycle::Open,
5327 checks: Checks::Green,
5328 blocking: Blocking::No,
5330 failing: Vec::new(),
5331 review_comments: vec![ReviewComment {
5332 author: "coderabbitai".to_owned(),
5333 path: Some("src/land.rs".to_owned()),
5334 line: Some(212),
5335 body: "this branch never checks the exit code".to_owned(),
5336 }],
5337 }
5338 }
5339
5340 #[test]
5341 fn github_facing_land_text_is_english_whatever_the_language() {
5342 let mut state = run_state();
5343 state.config.graph.language = "ja".to_owned();
5344 let comment = stop_comment(&state.id, "checks are still red");
5345 assert!(crate::github_text::check("", &comment).is_empty());
5346 assert!(comment.is_ascii(), "{comment}");
5347 assert!(comment.starts_with(MARKER));
5348
5349 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5350 let ja_at = p.find("Write all prose in ja").unwrap();
5351 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5352 assert!(ja_at < rule_at, "{p}");
5353 assert!(p.contains("stays in Japanese"), "{p}");
5354
5355 state.config.graph.language = "en".to_owned();
5356 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5357 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5358 assert!(!p.contains("does not apply"), "{p}");
5359 }
5360
5361 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5362
5363 fn panel() -> String {
5364 approval_panel(
5365 &run_state(),
5366 &green_pr(),
5367 NUMSTAT,
5368 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5369 &[
5370 "land: ask before merging".to_owned(),
5371 "land: colour the diff".to_owned(),
5372 ],
5373 "feat: merge approval from the phone",
5374 )
5375 }
5376
5377 #[test]
5378 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5379 let html = panel();
5380 for needle in [
5381 "42",
5382 "main",
5383 "src/land.rs",
5384 "src/web.rs",
5385 "assets/logo.png",
5386 "feat: merge approval from the phone",
5387 "land: ask before merging",
5388 "land: colour the diff",
5389 "coderabbitai",
5390 "this branch never checks the exit code",
5391 "green",
5392 ] {
5393 assert!(html.contains(needle), "the panel must state `{needle}`");
5394 }
5395 }
5396
5397 fn winning_candidate(summary: &str) -> Candidate {
5400 Candidate {
5401 index: 0,
5402 label: 'A',
5403 agent: "opus".to_owned(),
5404 branch: "magi/x/A".to_owned(),
5405 worktree: PathBuf::from("/wt/A"),
5406 summary: summary.to_owned(),
5407 stat: String::new(),
5408 files: 1,
5409 commits: 1,
5410 empty: false,
5411 failed: None,
5412 verified_noop: None,
5413 duration_ms: 0,
5414 folded: false,
5415 }
5416 }
5417
5418 fn uncontested_tally() -> Tally {
5419 Tally {
5420 first_choice: BTreeMap::from([('A', 1)]),
5421 borda: BTreeMap::new(),
5422 winner: 'A',
5423 rankings: 1,
5424 unanimous_initial: true,
5425 deliberated: false,
5426 changed_votes: 0,
5427 unanimous_final: true,
5428 tie_break: None,
5429 judges: 1,
5430 present: 1,
5431 quorum: 1,
5432 met_quorum: true,
5433 uncontested: None,
5434 }
5435 }
5436
5437 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5438 ReviewRecord {
5439 attempts: 0,
5440 reviewer,
5441 agent: agent.to_owned(),
5442 summary: summary.to_owned(),
5443 findings: Vec::new(),
5444 vote: None,
5445 failed: None,
5446 duration_ms: 0,
5447 }
5448 }
5449
5450 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5451 let answered = reviews.len();
5452 ReviewRound {
5453 round,
5454 head: "abc1234".to_owned(),
5455 verified_head: None,
5456 verified_at: None,
5457 reviews,
5458 e2e: Vec::new(),
5459 verify_retried: false,
5460 e2e_deferred: false,
5461 e2e_defer_reason: None,
5462 fix: None,
5463 blocking: 0,
5464 answered,
5465 expected: answered,
5466 clean: true,
5467 progressed: false,
5468 vote_split: false,
5469 reconsideration: Vec::new(),
5470 verdict: None,
5471 }
5472 }
5473
5474 #[test]
5475 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5476 let en = panel();
5477 assert!(en.contains("Task"), "{en}");
5478 assert!(en.contains("add retries to the uploader"), "{en}");
5479
5480 let mut state = run_state();
5481 state.config.graph.language = "ja".to_owned();
5482 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5483 assert!(ja.contains("タスク"), "{ja}");
5484 assert!(
5485 ja.contains("add retries to the uploader"),
5486 "the task itself is not translated: {ja}"
5487 );
5488 }
5489
5490 #[test]
5491 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5492 let html = panel();
5496 assert!(!html.contains("What changed"), "{html}");
5497 assert!(!html.contains("Review verdict"), "{html}");
5498 }
5499
5500 #[test]
5501 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5502 let mut state = run_state();
5503 state.candidates = vec![winning_candidate("")];
5504 state.tally = Some(uncontested_tally());
5505 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5506 assert!(
5507 !html.contains("What changed"),
5508 "an empty summary must not render an empty box: {html}"
5509 );
5510 }
5511
5512 #[test]
5513 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5514 let mut state = run_state();
5515 state.candidates = vec![winning_candidate(
5516 "Added a retry loop around the uploader PUT call.",
5517 )];
5518 state.tally = Some(uncontested_tally());
5519 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5520 assert!(en.contains("What changed"), "{en}");
5521 assert!(
5522 en.contains("Added a retry loop around the uploader PUT call."),
5523 "{en}"
5524 );
5525
5526 state.config.graph.language = "ja".to_owned();
5527 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5528 assert!(ja.contains("変更内容"), "{ja}");
5529 assert!(
5530 ja.contains("Added a retry loop around the uploader PUT call."),
5531 "{ja}"
5532 );
5533 }
5534
5535 #[test]
5536 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5537 let mut state = run_state();
5538 state.reviews = vec![
5539 review_round(
5540 1,
5541 vec![review_record(1, "alpha", "found a race, sent back")],
5542 ),
5543 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5544 ];
5545 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5546 assert!(en.contains("Review verdict"), "{en}");
5547 assert!(en.contains("race is fixed, clean"), "{en}");
5548 assert!(
5549 !en.contains("found a race, sent back"),
5550 "only the round that actually cleared the merge should show: {en}"
5551 );
5552
5553 state.config.graph.language = "ja".to_owned();
5554 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5555 assert!(ja.contains("レビューの結論"), "{ja}");
5556 assert!(ja.contains("レビュアー"), "{ja}");
5557 assert!(ja.contains("race is fixed, clean"), "{ja}");
5558 }
5559
5560 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5566 ReviewRecord {
5567 attempts: 0,
5568 reviewer,
5569 agent: agent.to_owned(),
5570 summary: String::new(),
5571 findings: Vec::new(),
5572 vote: None,
5573 failed: Some(reason.to_owned()),
5574 duration_ms: 0,
5575 }
5576 }
5577
5578 #[test]
5579 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5580 let mut state = run_state();
5581 state.reviews = vec![review_round(
5582 1,
5583 vec![
5584 review_record(1, "alpha", "clean, nothing to add"),
5585 unanswered_review_record(2, "beta", "timed out"),
5586 ],
5587 )];
5588 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5589 assert!(en.contains("clean, nothing to add"), "{en}");
5590 assert!(
5591 en.contains("produced no answer: timed out"),
5592 "a seat that never answered must say so, not render a blank box: {en}"
5593 );
5594 assert!(
5595 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5596 "no reviewer box may be left empty: {en}"
5597 );
5598
5599 state.config.graph.language = "ja".to_owned();
5600 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5601 assert!(ja.contains("回答なし: timed out"), "{ja}");
5602 }
5603
5604 #[test]
5605 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5606 let html = panel();
5607 assert!(!html.contains("<script"), "no script survives the csp");
5608 assert!(!html.contains("<form"), "form-action is 'none'");
5609 let pr = green_pr();
5610 assert_eq!(
5611 html.matches("http").count(),
5612 html.matches(pr.url.as_str()).count(),
5613 "the only http url in the panel is the pull request's own link"
5614 );
5615 }
5616
5617 #[test]
5618 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5619 let html = panel();
5620 assert!(
5621 html.contains(">+</span>"),
5622 "an added line carries a `+` in the gutter, not only a background"
5623 );
5624 assert!(
5625 html.contains(">-</span>"),
5626 "a removed line carries a `-` in the gutter, not only a background"
5627 );
5628 assert!(
5629 html.contains(">new line</span>"),
5630 "the marker is moved to the gutter, so the body is printed once without it"
5631 );
5632 }
5633
5634 #[test]
5635 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5636 let total = DIFF_MAX_LINES + 100;
5637 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5638 let html = approval_panel(
5639 &run_state(),
5640 &green_pr(),
5641 NUMSTAT,
5642 &diff,
5643 &[],
5644 "feat: something long",
5645 );
5646 assert!(
5647 html.contains(&format!("100 of {total} diff lines omitted")),
5648 "the note must say exactly how much was cut"
5649 );
5650 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5651 assert!(
5652 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5653 "nothing past the threshold is rendered"
5654 );
5655 assert!(
5656 html.contains("/repo/magi"),
5657 "the note says where the rest is"
5658 );
5659 }
5660
5661 #[test]
5662 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5663 let html = approval_panel(
5664 &run_state(),
5665 &green_pr(),
5666 "1\t2\tsrc/<b>&\"x\"'.rs",
5667 "",
5668 &[],
5669 "subject",
5670 );
5671 assert!(html.contains("src/<b>&"x"'.rs"));
5672 assert!(
5673 !html.contains("<b>"),
5674 "an agent-influenced path must never become markup"
5675 );
5676 }
5677
5678 #[tokio::test]
5679 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5680 let a = std::path::PathBuf::from("/repo/a");
5681 let b = std::path::PathBuf::from("/repo/b");
5682
5683 let held = repo_merge_lock(&a).lock_owned().await;
5684
5685 assert!(
5688 repo_merge_lock(&a).try_lock().is_err(),
5689 "a second merge into the same repository must not proceed concurrently"
5690 );
5691
5692 assert!(
5696 repo_merge_lock(&b).try_lock().is_ok(),
5697 "a different repository's merge lock must be independent"
5698 );
5699
5700 drop(held);
5701 assert!(
5702 repo_merge_lock(&a).try_lock().is_ok(),
5703 "the lock is released once the holder is done"
5704 );
5705 }
5706
5707 #[test]
5708 fn only_the_merge_choice_merges_and_silence_holds() {
5709 let table = [
5710 (None, Approval::Hold),
5711 (Some("merge"), Approval::Merge),
5712 (Some(" merge\n"), Approval::Merge),
5713 (Some("hold"), Approval::Hold),
5714 (Some(""), Approval::Hold),
5715 (Some("yes"), Approval::Hold),
5716 ];
5717 for (answer, want) in table {
5718 assert_eq!(
5719 approval(answer),
5720 want,
5721 "answer {answer:?} must resolve to {want:?}"
5722 );
5723 }
5724 }
5725
5726 #[tokio::test]
5727 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5728 let mut state = landing_state();
5729 state.config.graph.land_approval = true;
5730 let pr = green_pr();
5731
5732 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5733 .await
5734 .unwrap();
5735 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5736 assert!(
5737 !state.parked,
5738 "approval_gate itself never sets `parked`; only its caller does"
5739 );
5740
5741 let store = ask::Questions::open();
5742 let filed: Vec<_> = store
5743 .list()
5744 .into_iter()
5745 .filter(|q| q.run == state.id)
5746 .collect();
5747 assert_eq!(filed.len(), 1, "exactly one question is filed");
5748 assert_eq!(filed[0].node, APPROVAL_NODE);
5749 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5750 assert!(filed[0].status.open());
5751
5752 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5756 .await
5757 .unwrap();
5758 assert_eq!(again, ApprovalGate::Pending);
5759 let still_one = store
5760 .list()
5761 .into_iter()
5762 .filter(|q| q.run == state.id)
5763 .count();
5764 assert_eq!(
5765 still_one, 1,
5766 "asking twice must not double-file the question"
5767 );
5768 }
5769
5770 #[tokio::test]
5771 async fn approving_the_existing_question_is_read_back_as_approved() {
5772 crate::run::pin_test_home();
5773 let mut state = run_state();
5774 state.config.graph.land_approval = true;
5775 let pr = green_pr();
5776 assert_eq!(
5777 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5778 .await
5779 .unwrap(),
5780 ApprovalGate::Pending
5781 );
5782
5783 let store = ask::Questions::open();
5784 let mut q = store
5785 .list()
5786 .into_iter()
5787 .find(|q| q.run == state.id)
5788 .expect("filed above");
5789 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5790 store.put(&mut q).unwrap();
5791
5792 assert_eq!(
5793 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5794 .await
5795 .unwrap(),
5796 ApprovalGate::Approved
5797 );
5798 }
5799
5800 #[tokio::test]
5801 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5802 crate::run::pin_test_home();
5803 let store = ask::Questions::open();
5804
5805 let mut held_state = run_state();
5806 held_state.config.graph.land_approval = true;
5807 let pr = green_pr();
5808 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5809 .await
5810 .unwrap();
5811 let mut q = store
5812 .list()
5813 .into_iter()
5814 .find(|q| q.run == held_state.id)
5815 .expect("filed above");
5816 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5817 store.put(&mut q).unwrap();
5818 assert_eq!(
5819 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5820 .await
5821 .unwrap(),
5822 ApprovalGate::Held
5823 );
5824
5825 let mut abandoned_state = run_state();
5826 abandoned_state.config.graph.land_approval = true;
5827 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5828 .await
5829 .unwrap();
5830 let mut q = store
5831 .list()
5832 .into_iter()
5833 .find(|q| q.run == abandoned_state.id)
5834 .expect("filed above");
5835 q.abandon("no answer within the timeout");
5836 store.put(&mut q).unwrap();
5837 assert_eq!(
5838 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5839 .await
5840 .unwrap(),
5841 ApprovalGate::Held,
5842 "silence must never merge"
5843 );
5844 }
5845
5846 fn contested() -> ContestedHandoff {
5847 let finding = |id: &str, n: u32| crate::verdict::Finding {
5848 id: id.to_owned(),
5849 severity: crate::verdict::Severity::Major,
5850 file: Some("src/a.rs".to_owned()),
5851 line: Some(n),
5852 title: format!("problem {id}"),
5853 detail: String::new(),
5854 };
5855 ContestedHandoff {
5856 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5857 rejecters: vec![(1, "alpha".to_owned())],
5858 }
5859 }
5860
5861 #[test]
5862 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5863 let mut state = run_state();
5864 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5865 state.contested_handoff = Some(contested());
5866 assert!(contested_to_ask(&state).is_some());
5867 state.config.graph.hold_contested_merge = false;
5868 assert!(
5869 contested_to_ask(&state).is_none(),
5870 "the switch restores today"
5871 );
5872 }
5873
5874 #[test]
5875 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5876 let q = ask::Question::new(
5877 "run-1".to_owned(),
5878 APPROVAL_NODE.to_owned(),
5879 "land".to_owned(),
5880 "Merge?".to_owned(),
5881 String::new(),
5882 vec![APPROVE.to_owned(), HOLD.to_owned()],
5883 );
5884 let none = deputy_brief(&q, None);
5885 assert!(none.contains("could not be read"), "{none}");
5886 assert!(none.contains("Silence is a hold"), "{none}");
5887
5888 let mut state = run_state();
5889 state.pr = Some(crate::run::PrRecord {
5890 url: "https://example.test/pull/7".to_owned(),
5891 number: 7,
5892 state: "open".to_owned(),
5893 checks: "green".to_owned(),
5894 round: 0,
5895 rounds: 3,
5896 red_at_merge: Vec::new(),
5897 });
5898 state.contested_handoff = Some(contested());
5899 let b = deputy_brief(&q, Some(&state));
5900 assert!(b.contains("https://example.test/pull/7"), "{b}");
5901 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5902 assert!(b.contains("#1"), "the rejecting seat: {b}");
5903 state.contested_handoff = None;
5904 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5905 }
5906
5907 #[test]
5908 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5909 for lang in ["en", "ja"] {
5910 let mut cfg = crate::config::Config::default();
5911 cfg.graph.language = lang.to_owned();
5912 let w = words(&cfg.graph.language);
5913 let text = w.approval_detail(
5914 "https://github.com/yukimemi/magi/pull/42",
5915 "main",
5916 "feat: x",
5917 Some(&contested()),
5918 );
5919 assert!(text.contains("pull/42"), "{text}");
5920 assert!(
5921 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5922 "{text}"
5923 );
5924 assert!(text.contains("R3-1-5"), "{text}");
5925 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5926 assert!(text.contains("2"), "the rest are counted: {text}");
5927 assert!(text.contains("#1 (alpha)"), "{text}");
5928 }
5929 let plain = words("en").approval_detail("u", "main", "s", None);
5930 assert!(!plain.contains("reject"), "{plain}");
5931 }
5932
5933 #[tokio::test]
5934 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5935 crate::run::pin_test_home();
5936 let mut state = run_state();
5937 state.config.graph.land_approval = false;
5938 state.contested_handoff = Some(contested());
5939 let pr = green_pr();
5940 let c = contested_to_ask(&state);
5941 assert_eq!(
5942 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5943 .await
5944 .unwrap(),
5945 ApprovalGate::Pending,
5946 "silence is a hold"
5947 );
5948 let store = ask::Questions::open();
5949 let filed: Vec<_> = store
5950 .list()
5951 .into_iter()
5952 .filter(|q| q.run == state.id)
5953 .collect();
5954 assert_eq!(filed.len(), 1);
5955 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5956
5957 assert_eq!(
5958 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5959 .await
5960 .unwrap(),
5961 ApprovalGate::Pending
5962 );
5963 let mut q = store
5964 .list()
5965 .into_iter()
5966 .find(|q| q.run == state.id)
5967 .unwrap();
5968 assert_eq!(q.id, filed[0].id, "the same question after a resume");
5969 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5970 store.put(&mut q).unwrap();
5971 assert_eq!(
5972 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5973 .await
5974 .unwrap(),
5975 ApprovalGate::Approved
5976 );
5977 }
5978
5979 #[test]
5980 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5981 let rows = parse_numstat(NUMSTAT);
5982 assert_eq!(
5983 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5984 ["src/web.rs", "src/land.rs", "assets/logo.png"]
5985 );
5986 assert_eq!(rows[2].added, None, "a binary file has no line counts");
5987 }
5988 #[test]
5989 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5990 let mut state = run_state();
5994 state.config.graph.language = "ja".to_owned();
5995 let pr = green_pr();
5996 let commits = ["c1".to_owned()];
5997
5998 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5999 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6000 assert!(ja.contains("squash されるコミット"), "{ja}");
6001 assert!(ja.contains("レビューコメント"), "{ja}");
6002 assert!(ja.contains("差分"), "{ja}");
6003 assert!(
6004 !ja.contains("Commits being squashed"),
6005 "no English left over"
6006 );
6007
6008 let w = words("ja");
6009 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6010 assert!(
6011 w.approval_detail("http://x/1", "main", "feat: x", None)
6012 .contains("パネル")
6013 );
6014
6015 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6017 assert!(ja.contains("feat: x"), "nor is the merge subject");
6018
6019 state.config.graph.language = "en".to_owned();
6022 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6023 assert!(en.contains("Commits being squashed"), "{en}");
6024 assert_eq!(words("Klingon").html_lang, "en");
6025 }
6026
6027 #[test]
6031 fn pick_open_pr_classifies_by_count_and_base() {
6032 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6033 assert_eq!(
6034 pick_open_pr(one, "main").unwrap(),
6035 OpenPr::One {
6036 url: "https://x/pull/58".into(),
6037 title: "t".into()
6038 }
6039 );
6040 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6041 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6042 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6043 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6044 assert_eq!(
6045 pick_open_pr(two, "main").unwrap(),
6046 OpenPr::Many(vec!["u1".into(), "u2".into()])
6047 );
6048 assert!(pick_open_pr("not json", "main").is_err());
6049 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6051 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6052 }
6053
6054 #[test]
6055 fn pick_merged_pr_picks_the_unique_match() {
6056 let json = r#"[
6057 {"url": "https://github.com/o/r/pull/42", "number": 42,
6058 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6059 ]"#;
6060 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6061 let found = pick_merged_pr(json, "main", created_at)
6062 .expect("valid json")
6063 .expect("one unambiguous match");
6064 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6065 assert_eq!(found.number, 42);
6066 }
6067
6068 #[test]
6072 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6073 let json = r#"[
6074 {"url": "https://github.com/o/r/pull/42", "number": 42,
6075 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6076 {"url": "https://github.com/o/r/pull/43", "number": 43,
6077 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6078 ]"#;
6079 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6080 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6081 }
6082
6083 #[test]
6087 fn pick_merged_pr_ignores_a_different_base_branch() {
6088 let json = r#"[
6089 {"url": "https://github.com/o/r/pull/42", "number": 42,
6090 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6091 ]"#;
6092 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6093 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6094 }
6095
6096 #[test]
6099 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6100 let json = r#"[
6101 {"url": "https://github.com/o/r/pull/42", "number": 42,
6102 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6103 ]"#;
6104 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6105 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6106 }
6107
6108 #[test]
6109 fn slug_of_pr_url_reads_host_owner_and_repo() {
6110 assert_eq!(
6111 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6112 Some("github.com/yukimemi/shun")
6113 );
6114 }
6115
6116 #[test]
6117 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6118 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6119 assert_eq!(slug_of_pr_url("not a url at all"), None);
6120 assert_eq!(slug_of_pr_url("https://github.com"), None);
6121 }
6122
6123 #[test]
6124 fn slug_of_repo_url_reads_host_owner_and_repo() {
6125 assert_eq!(
6126 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6127 Some("github.com/yukimemi/magi")
6128 );
6129 assert_eq!(slug_of_repo_url("https://github.com"), None);
6130 }
6131
6132 #[test]
6133 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6134 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6135 .expect("same repo, different case");
6136 }
6137
6138 #[test]
6143 fn ensure_same_repo_refuses_a_different_repo() {
6144 let err =
6145 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6146 let msg = format!("{err:#}");
6147 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6148 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6149 }
6150
6151 #[test]
6156 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6157 let err = ensure_same_repo(
6158 "github.com/yukimemi/magi",
6159 "github.example.com/yukimemi/magi",
6160 )
6161 .unwrap_err();
6162 let msg = format!("{err:#}");
6163 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6164 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6165 }
6166
6167 #[tokio::test]
6172 async fn find_external_merge_returns_none_without_a_winner() {
6173 let state = RunState::new(
6174 PathBuf::from("/no/such/repo"),
6175 "main".to_owned(),
6176 "0000000000000000000000000000000000000000".to_owned(),
6177 "irrelevant".to_owned(),
6178 crate::config::Config::default(),
6179 );
6180 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6181 }
6182
6183 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6184 let mut run = RunState::new(
6185 PathBuf::from(repo),
6186 "main".to_owned(),
6187 "abcdef1234".to_owned(),
6188 "x".to_owned(),
6189 crate::config::Config::default(),
6190 );
6191 run.id = id.to_owned();
6192 run.status = status;
6193 run.pr = Some(crate::run::PrRecord {
6194 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6195 url: url.to_owned(),
6196 state: state.to_owned(),
6197 checks: "red".to_owned(),
6198 round: 0,
6199 rounds: 2,
6200 red_at_merge: Vec::new(),
6201 });
6202 run.save_under(home).unwrap();
6203 }
6204
6205 fn recorded(home: &Path, id: &str) -> String {
6206 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6207 serde_json::from_str::<RunState>(&body)
6208 .unwrap()
6209 .pr
6210 .unwrap()
6211 .state
6212 }
6213
6214 const PR: &str = "https://github.com/o/r/pull/7";
6215
6216 #[test]
6217 fn write_through_updates_predecessors_and_siblings_only() {
6218 let tmp = tempfile::tempdir().unwrap();
6219 let h = tmp.path();
6220 pr_run(
6221 h,
6222 "20261004-100000-aaaa",
6223 "/repo/r",
6224 RunStatus::Superseded,
6225 PR,
6226 "open",
6227 );
6228 pr_run(
6229 h,
6230 "20261004-100100-bbbb",
6231 "/repo/r",
6232 RunStatus::Blocked,
6233 PR,
6234 "open",
6235 );
6236 pr_run(
6238 h,
6239 "20261004-100200-cccc",
6240 "/repo/r",
6241 RunStatus::Landing,
6242 PR,
6243 "open",
6244 );
6245 pr_run(
6247 h,
6248 "20261004-100300-dddd",
6249 "/repo/other",
6250 RunStatus::Blocked,
6251 "https://github.com/o/other/pull/7",
6252 "open",
6253 );
6254 pr_run(
6256 h,
6257 "20261004-100400-eeee",
6258 "/repo/r",
6259 RunStatus::Blocked,
6260 "https://github.com/o/r/pull/8",
6261 "open",
6262 );
6263 pr_run(
6264 h,
6265 "20261004-100500-ffff",
6266 "/repo/r",
6267 RunStatus::Merged,
6268 PR,
6269 "open",
6270 );
6271 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6272
6273 assert_eq!(
6274 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6275 2
6276 );
6277 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6278 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6279 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6280 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6281 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6282 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6284 assert_eq!(
6286 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6287 0
6288 );
6289 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6290 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6291 }
6292
6293 #[test]
6294 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6295 let tmp = tempfile::tempdir().unwrap();
6296 let h = tmp.path();
6297 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6298 pr_run(
6299 h,
6300 "20261004-100000-aaaa",
6301 "/repo/r",
6302 RunStatus::Superseded,
6303 &url(1),
6304 "open",
6305 );
6306 pr_run(
6307 h,
6308 "20261004-100100-bbbb",
6309 "/repo/r",
6310 RunStatus::Blocked,
6311 &url(2),
6312 "open",
6313 );
6314 pr_run(
6315 h,
6316 "20261004-100200-cccc",
6317 "/repo/r",
6318 RunStatus::Ready,
6319 &url(3),
6320 "open",
6321 );
6322 pr_run(
6323 h,
6324 "20261004-100300-dddd",
6325 "/repo/r",
6326 RunStatus::Ready,
6327 &url(4),
6328 "open",
6329 );
6330 pr_run(
6331 h,
6332 "20261004-100400-eeee",
6333 "/repo/r",
6334 RunStatus::Implementing,
6335 &url(1),
6336 "open",
6337 );
6338 assert_eq!(stale_open_prs(h).len(), 4);
6339
6340 let mut known = BTreeMap::new();
6341 known.insert(url(1), PrLifecycle::Merged);
6342 known.insert(url(2), PrLifecycle::Closed);
6343 known.insert(url(3), PrLifecycle::Open);
6344 assert_eq!(apply_pr_states(h, &known), 2);
6346 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6347 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6348 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6349 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6350 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6351 assert_eq!(apply_pr_states(h, &known), 0);
6352 }
6353
6354 use std::collections::VecDeque;
6357 use std::sync::Mutex;
6358
6359 struct Scripted {
6362 views: Mutex<VecDeque<Seen>>,
6363 merges: Mutex<VecDeque<(bool, String)>>,
6364 fix: Mutex<Option<Fixed>>,
6365 log: Mutex<Vec<&'static str>>,
6366 argvs: Mutex<Vec<Vec<String>>>,
6367 required: Mutex<Option<BTreeSet<String>>>,
6368 merged: Mutex<bool>,
6371 queued: Mutex<bool>,
6372 unreadable_after_merge: Mutex<bool>,
6374 }
6375
6376 impl Scripted {
6377 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6378 Self {
6379 views: Mutex::new(views.into()),
6380 merges: Mutex::new(
6381 merges
6382 .into_iter()
6383 .map(|(ok, m)| (ok, m.to_owned()))
6384 .collect(),
6385 ),
6386 fix: Mutex::new(None),
6387 log: Mutex::new(Vec::new()),
6388 argvs: Mutex::new(Vec::new()),
6389 required: Mutex::new(None),
6390 merged: Mutex::new(false),
6391 queued: Mutex::new(false),
6392 unreadable_after_merge: Mutex::new(false),
6393 }
6394 }
6395 fn argvs(&self) -> Vec<Vec<String>> {
6396 self.argvs.lock().unwrap().clone()
6397 }
6398 fn calls(&self) -> Vec<&'static str> {
6399 self.log.lock().unwrap().clone()
6400 }
6401 }
6402
6403 impl Forge for Scripted {
6404 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6405 self.log.lock().unwrap().push("view");
6406 if *self.unreadable_after_merge.lock().unwrap()
6407 && !self.argvs.lock().unwrap().is_empty()
6408 {
6409 anyhow::bail!("forge unreachable");
6410 }
6411 let mut v = self.views.lock().unwrap();
6412 let mut seen = if v.len() > 1 {
6413 v.pop_front().unwrap()
6414 } else {
6415 v[0].clone()
6416 };
6417 if *self.merged.lock().unwrap() {
6418 seen.pr.state = PrLifecycle::Merged;
6419 }
6420 Ok(seen)
6421 }
6422 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6423 self.log.lock().unwrap().push("merge");
6424 self.argvs.lock().unwrap().push(argv.to_vec());
6425 let out = self
6426 .merges
6427 .lock()
6428 .unwrap()
6429 .pop_front()
6430 .expect("unscripted merge");
6431 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6432 {
6433 *self.merged.lock().unwrap() = true;
6434 }
6435 Ok(out)
6436 }
6437 async fn poll(&self) {
6438 self.log.lock().unwrap().push("poll");
6439 }
6440 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6441 self.required.lock().unwrap().clone()
6442 }
6443 async fn fix(
6444 &self,
6445 _state: &mut RunState,
6446 _pr: &PrState,
6447 _round: usize,
6448 _budget: usize,
6449 _reason: &str,
6450 _logs: &str,
6451 ) -> Result<Fixed> {
6452 self.log.lock().unwrap().push("fix");
6453 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6454 }
6455 }
6456
6457 const REFUSED: &str =
6458 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6459
6460 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6461 let mut pr = green_pr();
6462 pr.checks = checks;
6463 pr.blocking = Blocking::of(merge_state);
6464 if !comments {
6465 pr.review_comments.clear();
6466 }
6467 Seen {
6468 pr,
6469 title: "feat: x".to_owned(),
6470 failing_urls: Vec::new(),
6471 head: head.to_owned(),
6472 rollup_head: head.to_owned(),
6473 merge_state: merge_state.to_owned(),
6474 contexts: Vec::new(),
6475 base: "main".to_owned(),
6476 }
6477 }
6478
6479 fn landing_state() -> RunState {
6480 crate::run::pin_test_home();
6481 let mut state = run_state();
6482 state.config.graph.land_approval = false;
6483 state
6484 }
6485
6486 #[test]
6487 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6488 assert!(!awaiting_new_head(None, "aaa"));
6489 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6490 assert!(awaiting_new_head(Some("abc123"), "def456"));
6491 assert!(awaiting_new_head(Some("abc123"), ""));
6492 }
6493
6494 #[test]
6495 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6496 let open = |c, m: &str| seen("a", c, m, false);
6497 let table = [
6498 (None, false, Refused::Pending),
6499 (
6500 Some(open(Checks::Pending, "BLOCKED")),
6501 false,
6502 Refused::Pending,
6503 ),
6504 (
6505 Some(open(Checks::Unknown, "BLOCKED")),
6506 false,
6507 Refused::Pending,
6508 ),
6509 (
6510 Some(open(Checks::Green, "UNKNOWN")),
6511 false,
6512 Refused::Pending,
6513 ),
6514 (Some(open(Checks::Green, "")), false, Refused::Pending),
6515 (
6516 Some(open(Checks::Green, "BLOCKED")),
6517 false,
6518 Refused::Recheck,
6519 ),
6520 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6521 ];
6522 for (after, rechecked, want) in table {
6523 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6524 }
6525 let mut closed = open(Checks::Green, "CLEAN");
6526 closed.pr.state = PrLifecycle::Closed;
6527 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6528 }
6529
6530 #[tokio::test]
6531 async fn a_normal_landing_merges_on_the_first_look() {
6532 let mut state = landing_state();
6533 let forge = Scripted::new(
6534 vec![seen("a", Checks::Green, "CLEAN", false)],
6535 vec![(true, "")],
6536 );
6537 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6538 .await
6539 .unwrap();
6540 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6542 assert_eq!(state.status, RunStatus::Merged);
6543 }
6544
6545 #[tokio::test]
6546 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6547 let mut state = landing_state();
6548 let forge = Scripted::new(
6549 vec![seen("a", Checks::Green, "CLEAN", false)],
6550 std::iter::repeat_n((true, ""), 100).collect(),
6551 );
6552 *forge.queued.lock().unwrap() = true;
6553 let task = async {
6554 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6555 .await
6556 .unwrap();
6557 };
6558 task.await;
6561 assert_ne!(state.status, RunStatus::Merged);
6562 }
6563
6564 #[tokio::test]
6565 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6566 let mut state = landing_state();
6567 let forge = Scripted::new(
6568 vec![seen("a", Checks::Green, "CLEAN", false)],
6569 std::iter::repeat_n((true, ""), 100).collect(),
6570 );
6571 *forge.unreadable_after_merge.lock().unwrap() = true;
6572 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6573 .await
6574 .ok();
6575 assert_ne!(state.status, RunStatus::Merged);
6576 }
6577
6578 #[tokio::test]
6579 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6580 let mut state = landing_state();
6581 let forge = Scripted::new(
6582 vec![
6583 seen("old", Checks::Green, "CLEAN", true),
6584 seen("old", Checks::Green, "CLEAN", true),
6586 seen("new", Checks::Pending, "BLOCKED", true),
6587 seen("new", Checks::Green, "CLEAN", true),
6588 ],
6589 vec![(true, "")],
6590 );
6591 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6592 head: "NEW".to_owned(),
6593 });
6594 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6595 .await
6596 .unwrap();
6597 assert_eq!(
6598 forge.calls(),
6599 [
6600 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6601 "view"
6602 ]
6603 );
6604 assert_eq!(state.status, RunStatus::Merged);
6605 }
6606
6607 #[tokio::test]
6608 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6609 let mut state = landing_state();
6610 let forge = Scripted::new(
6611 vec![
6612 seen("old", Checks::Green, "CLEAN", true),
6613 seen("someone-elses", Checks::Green, "CLEAN", true),
6614 ],
6615 vec![],
6616 );
6617 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6618 head: "mine".to_owned(),
6619 });
6620 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6621 .await
6622 .unwrap();
6623 assert!(!forge.calls().contains(&"merge"));
6624 let why = state.merge.as_ref().unwrap().detail.clone();
6625 assert!(
6626 why.contains("mine") && why.contains("someone-elses"),
6627 "{why}"
6628 );
6629 assert_eq!(state.status, RunStatus::Blocked);
6630 }
6631
6632 #[tokio::test]
6633 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6634 let mut state = landing_state();
6635 let forge = Scripted::new(
6636 vec![
6637 seen("a", Checks::Green, "CLEAN", false),
6638 seen("a", Checks::Green, "CLEAN", false),
6639 seen("a", Checks::Pending, "BLOCKED", false),
6640 seen("a", Checks::Pending, "BLOCKED", false),
6641 seen("a", Checks::Green, "CLEAN", false),
6642 ],
6643 vec![(false, REFUSED), (true, "")],
6644 );
6645 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6646 .await
6647 .unwrap();
6648 assert_eq!(
6649 forge.calls(),
6650 [
6651 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6652 "view"
6653 ]
6654 );
6655 assert_eq!(state.status, RunStatus::Merged);
6656 }
6657
6658 #[tokio::test]
6659 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6660 let mut state = landing_state();
6661 let forge = Scripted::new(
6662 vec![
6663 seen("a", Checks::Green, "CLEAN", false),
6664 seen("a", Checks::Green, "BLOCKED", false),
6665 ],
6666 vec![(false, REFUSED), (false, REFUSED)],
6667 );
6668 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6669 .await
6670 .unwrap();
6671 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6673 let why = state.merge.as_ref().unwrap().detail.clone();
6674 assert!(
6675 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6676 "{why}"
6677 );
6678 assert_eq!(state.status, RunStatus::Blocked);
6679 }
6680
6681 #[test]
6682 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6683 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6684 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6685 assert_eq!(bound_head("old", "old", Some("new")), None);
6687 assert_eq!(bound_head("new", "old", Some("new")), None);
6689 assert_eq!(bound_head("new", "old", None), None);
6690 assert_eq!(bound_head("", "", None), None);
6692 assert_eq!(bound_head("", "", Some("new")), None);
6693 assert_eq!(bound_head("abc", "", None), None);
6694 }
6695
6696 fn view_json(head: &str) -> String {
6697 format!(
6698 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6699 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6700 "reviews":[],"comments":[]}}"#
6701 )
6702 }
6703
6704 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6705 format!(
6706 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6707 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6708 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6709 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6710 )
6711 }
6712
6713 #[test]
6714 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6715 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6718 assert_eq!(s.rollup_head, "old");
6719 assert_eq!(s.pr.checks, Checks::Red);
6720 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6721 assert_eq!(s.failing_urls.len(), 1);
6722 }
6723
6724 #[test]
6725 fn checks_come_from_the_node_not_the_view() {
6726 let view = view_json("new").replace(
6727 r#""reviews""#,
6728 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6729 );
6730 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6731 assert_eq!(s.pr.checks, Checks::Green);
6732 assert!(s.pr.failing.is_empty());
6733 assert_eq!(
6734 bound_head(&s.head, &s.rollup_head, Some("new")),
6735 Some("new")
6736 );
6737 }
6738
6739 #[test]
6740 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6741 for node in [
6742 None,
6743 Some("not json".to_owned()),
6744 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6745 Some(node_json("new", "SUCCESS", true)),
6746 ] {
6747 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6748 assert!(s.rollup_head.is_empty());
6749 assert_eq!(s.pr.checks, Checks::Unknown);
6750 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6751 }
6752 }
6753
6754 #[test]
6755 fn the_merge_command_is_pinned_to_the_observed_head() {
6756 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6757 let at = argv
6758 .iter()
6759 .position(|a| a == "--match-head-commit")
6760 .unwrap();
6761 assert_eq!(argv[at + 1], "deadbeef");
6762 }
6763
6764 #[tokio::test]
6765 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6766 let mut state = landing_state();
6767 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6770 stale.rollup_head = "old".to_owned();
6771 let forge = Scripted::new(
6772 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6773 vec![],
6774 );
6775 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6776 head: "new".to_owned(),
6777 });
6778 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6779 .await
6780 .unwrap();
6781 assert!(!forge.calls().contains(&"merge"));
6782 assert_eq!(state.status, RunStatus::Blocked);
6783 let why = state.merge.as_ref().unwrap().detail.clone();
6784 assert!(why.contains("new") && why.contains("old"), "{why}");
6785 }
6786
6787 #[test]
6788 fn a_refusal_read_against_another_commits_checks_is_pending() {
6789 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6790 after.rollup_head = "old".to_owned();
6791 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6792 }
6793
6794 #[tokio::test]
6795 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6796 let mut state = landing_state();
6797 let forge = Scripted::new(
6798 vec![seen("a", Checks::Red, "CLEAN", false)],
6799 vec![(true, "")],
6800 );
6801 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6802 .await
6803 .unwrap();
6804 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6805 assert_eq!(state.status, RunStatus::Merged);
6806 }
6807
6808 #[tokio::test]
6809 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6810 let mut state = landing_state();
6811 let forge = Scripted::new(
6812 vec![
6813 seen("a", Checks::Green, "CLEAN", false),
6814 seen("a", Checks::Green, "CLEAN", false),
6815 seen("b", Checks::Green, "BLOCKED", false),
6817 seen("b", Checks::Green, "CLEAN", false),
6818 ],
6819 vec![(false, REFUSED), (true, "")],
6820 );
6821 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6822 .await
6823 .unwrap();
6824 assert_eq!(
6825 forge.calls(),
6826 [
6827 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6828 ]
6829 );
6830 assert_eq!(state.status, RunStatus::Merged);
6831 }
6832
6833 fn merged_view(head: &str) -> Seen {
6834 let mut m = seen(head, Checks::Green, "CLEAN", false);
6835 m.pr.state = PrLifecycle::Merged;
6836 m
6837 }
6838
6839 fn has(argv: &[String], flag: &str) -> bool {
6840 argv.iter().any(|a| a == flag)
6841 }
6842
6843 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6844 let at = argv.iter().position(|a| a == flag)?;
6845 argv.get(at + 1).map(String::as_str)
6846 }
6847
6848 const URL: &str = "https://github.com/o/r/pull/42";
6849
6850 #[tokio::test]
6851 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6852 let mut state = landing_state();
6853 let forge = Scripted::new(
6854 vec![
6855 seen("abc", Checks::Green, "CLEAN", false),
6856 seen("abc", Checks::Green, "CLEAN", false),
6857 ],
6858 vec![(true, "")],
6859 );
6860 land_with(&mut state, URL, &forge).await.unwrap();
6861 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6862 let argv = &forge.argvs()[0];
6863 assert!(has(argv, "--squash") && has(argv, "--subject"));
6864 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6865 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6866 assert_eq!(state.status, RunStatus::Merged);
6867 assert!(state.land_armed_head.is_none());
6868 }
6869
6870 #[tokio::test]
6871 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6872 let mut state = landing_state();
6873 state.land_armed_head = Some("a".to_owned());
6874 let forge = Scripted::new(
6875 vec![seen("a", Checks::Green, "CLEAN", false)],
6876 vec![(true, ""), (true, "")],
6877 );
6878 land_with(&mut state, URL, &forge).await.unwrap();
6879 let argvs = forge.argvs();
6880 assert!(has(&argvs[0], "--disable-auto"));
6881 assert!(!has(&argvs[1], "--auto"));
6882 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6883 assert_eq!(state.status, RunStatus::Merged);
6884 assert!(state.land_armed_head.is_none());
6885 }
6886
6887 #[tokio::test]
6888 async fn an_approval_never_carries_over_to_a_new_head() {
6889 crate::run::pin_test_home();
6890 let mut state = run_state();
6891 state.config.graph.land_approval = true;
6892 let pr = green_pr();
6893 let store = ask::Questions::open();
6894
6895 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6896 .await
6897 .unwrap();
6898 let mut q = store
6899 .list()
6900 .into_iter()
6901 .find(|q| q.run == state.id)
6902 .unwrap();
6903 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6904 store.put(&mut q).unwrap();
6905 assert_eq!(
6906 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6907 .await
6908 .unwrap(),
6909 ApprovalGate::Approved,
6910 "the same head keeps its approval"
6911 );
6912
6913 assert_eq!(
6915 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6916 .await
6917 .unwrap(),
6918 ApprovalGate::Pending
6919 );
6920 let all: Vec<_> = store
6921 .list()
6922 .into_iter()
6923 .filter(|q| q.run == state.id)
6924 .collect();
6925 assert_eq!(all.len(), 2);
6926
6927 state.land_approval = None;
6929 assert_eq!(
6930 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6931 .await
6932 .unwrap(),
6933 ApprovalGate::Pending
6934 );
6935 let open = store
6936 .list()
6937 .into_iter()
6938 .filter(|q| q.run == state.id && q.status.open())
6939 .count();
6940 assert_eq!(open, 1, "the superseded question was retired");
6941 }
6942
6943 #[tokio::test]
6944 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6945 let mut state = landing_state();
6946 let forge = Scripted::new(
6947 vec![
6948 seen("a", Checks::Green, "CLEAN", false),
6949 seen("b", Checks::Green, "CLEAN", false),
6951 ],
6952 vec![(true, "")],
6953 );
6954 land_with(&mut state, URL, &forge).await.unwrap();
6955 let argvs = forge.argvs();
6956 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6957 assert!(!has(&argvs[0], "--auto"));
6958 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6959 assert_eq!(state.status, RunStatus::Merged);
6960 }
6961
6962 fn passing(label: &str) -> CheckInfo {
6963 CheckInfo {
6964 label: label.to_owned(),
6965 verdict: Verdict::Pass,
6966 required: Some(false),
6967 }
6968 }
6969
6970 fn names(xs: &[&str]) -> BTreeSet<String> {
6971 xs.iter().map(|x| (*x).to_owned()).collect()
6972 }
6973
6974 #[test]
6975 fn a_required_check_the_rollup_never_listed_is_named() {
6976 let req = names(&["build"]);
6977 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6978 assert!(why.contains("never reported: build"), "{why}");
6979 assert!(!why.contains("probably waiting for a review"), "{why}");
6980 }
6981
6982 #[test]
6983 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6984 let why = waiting_on("BLOCKED", &[passing("review")], None);
6985 assert!(why.contains("could not be read"), "{why}");
6986 assert!(!why.contains("probably waiting for a review"), "{why}");
6987 }
6988
6989 #[test]
6990 fn all_required_reported_keeps_the_review_guess() {
6991 let req = names(&["build"]);
6992 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6993 assert!(why.contains("probably waiting for a review"), "{why}");
6994 assert!(!why.contains("never reported"), "{why}");
6995 }
6996
6997 #[test]
6998 fn required_names_match_the_rollup_ignoring_case_only() {
6999 let req = names(&["Build"]);
7000 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7001 assert!(!why.contains("never reported"), "{why}");
7002 }
7003
7004 #[test]
7005 fn required_contexts_are_read_from_protection_and_rulesets() {
7006 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7007 assert_eq!(
7008 parse_classic_required(classic),
7009 Some(names(&["build", "lint"]))
7010 );
7011 let rules = r#"[{"type":"pull_request","parameters":{}},
7012 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7013 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7014 assert_eq!(parse_ruleset_required("nope"), None);
7015 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7016 }
7017
7018 #[test]
7019 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7020 let shown = BTreeSet::new();
7021 let ok = seen("a", Checks::Green, "CLEAN", false);
7022 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7023 assert!(guard(Some(&ok)));
7024 assert!(!guard(None));
7025 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7026 let mut stale = ok.clone();
7027 stale.rollup_head = "old".to_owned();
7028 assert!(!guard(Some(&stale)));
7029 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7030 assert!(!guard(Some(&merged_view("a"))));
7031 }
7032
7033 #[test]
7034 fn the_rollup_node_carries_whether_each_check_is_required() {
7035 let node = node_json("new", "SUCCESS", false)
7036 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7037 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7038 assert_eq!(s.contexts.len(), 1);
7039 assert_eq!(s.contexts[0].required, Some(true));
7040 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7041 assert_eq!(s.contexts[0].required, None);
7042 }
7043
7044 #[tokio::test]
7045 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7046 let mut state = landing_state();
7047 state.land_armed_head = Some("a".to_owned());
7048 let forge = Scripted::new(
7049 vec![seen("a", Checks::Green, "CLEAN", true)],
7050 vec![(false, "disable exploded")],
7051 );
7052 land_with(&mut state, URL, &forge).await.unwrap();
7053 assert!(!forge.calls().contains(&"fix"));
7054 assert_eq!(state.status, RunStatus::Blocked);
7055 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7056 let why = state.merge.as_ref().unwrap().detail.clone();
7057 assert!(why.contains("disable exploded"), "{why}");
7058 }
7059}