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