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