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