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