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