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::{
58 ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61pub const POLL: Duration = Duration::from_secs(30);
67
68pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88const LOG_TAIL: usize = 4_000;
91
92const MAX_LOGS: usize = 3;
95
96pub const MARKER: &str = "<!-- magi:land -->";
102
103const NOT_A_REVIEW: [&str; 3] = [
112 "skip review by coderabbit.ai",
113 "summarize by coderabbit.ai",
114 "<!-- tips_start -->",
115];
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120 Open,
122 Merged,
124 Closed,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131 Pending,
133 Green,
136 Red,
138 Unknown,
140}
141
142impl PrLifecycle {
143 pub fn as_str(self) -> &'static str {
145 match self {
146 Self::Open => "open",
147 Self::Merged => "merged",
148 Self::Closed => "closed",
149 }
150 }
151}
152
153impl Checks {
154 pub fn as_str(self) -> &'static str {
156 match self {
157 Self::Pending => "pending",
158 Self::Green => "green",
159 Self::Red => "red",
160 Self::Unknown => "unknown",
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168 pub author: String,
170 pub path: Option<String>,
172 pub line: Option<u64>,
174 pub body: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181 pub url: String,
183 pub number: u64,
185 pub state: PrLifecycle,
187 pub checks: Checks,
189 pub failing: Vec<String>,
191 pub review_comments: Vec<ReviewComment>,
193 pub blocking: Blocking,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211 No,
213 Yes,
215 Conflict,
217 Unsaid,
221}
222
223impl Blocking {
224 fn of(raw: &str) -> Self {
226 match raw.to_ascii_uppercase().as_str() {
227 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230 "DIRTY" => Self::Conflict,
231 "" | "UNKNOWN" => Self::Unsaid,
232 _ => Self::Yes,
234 }
235 }
236
237 #[must_use]
239 pub fn stops_a_merge(self) -> bool {
240 !matches!(self, Self::No)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247 Wait,
249 Rebase,
257 Fix {
259 reason: String,
261 },
262 Merge,
264 Done {
266 merged: bool,
268 },
269 GiveUp {
271 reason: String,
273 },
274}
275
276pub(crate) fn merged_after_all(
298 argv: &[String],
299 stderr: &str,
300 after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302 if after? != PrLifecycle::Merged {
303 return None;
304 }
305 Some(MergeOutcome {
306 mode: MergeMode::Pr,
307 ok: true,
308 detail: format!(
309 "gh {} (the command reported `{}`, but the pull request is merged)",
310 argv.join(" "),
311 stderr.trim()
312 ),
313 empty: false,
314 })
315}
316
317pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337 decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343 Expected,
346 Absent,
351}
352
353pub fn decide_with(
358 pr: &PrState,
359 round: usize,
360 budget: usize,
361 waited: Duration,
362 ci: CiExpectation,
363) -> Step {
364 match pr.state {
365 PrLifecycle::Merged => return Step::Done { merged: true },
366 PrLifecycle::Closed => return Step::Done { merged: false },
367 PrLifecycle::Open => {}
368 }
369
370 if pr.blocking == Blocking::Conflict {
373 return Step::Rebase;
374 }
375
376 if ci == CiExpectation::Absent {
377 return Step::Merge;
378 }
379
380 let spent = round >= budget;
381 match pr.checks {
382 Checks::Pending => Step::Wait,
383 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384 Checks::Unknown => Step::GiveUp {
385 reason: format!(
386 "no check status is readable on the pull request after {} minute(s); \
387 refusing to merge on a guess",
388 CHECKS_GRACE.as_secs() / 60
389 ),
390 },
391 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398 Checks::Red => {
399 let what = format!(
400 "{} check(s) failing: {}",
401 pr.failing.len(),
402 pr.failing.join(", ")
403 );
404 if spent {
405 Step::GiveUp {
406 reason: format!("{what} — still red after {budget} fix round(s)"),
407 }
408 } else {
409 Step::Fix { reason: what }
410 }
411 }
412 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413 Checks::Green => {
414 let what = format!(
415 "checks are green but {} review comment(s) are unresolved: {}",
416 pr.review_comments.len(),
417 authors(&pr.review_comments)
418 );
419 if spent {
420 Step::GiveUp {
421 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422 }
423 } else {
424 Step::Fix { reason: what }
425 }
426 }
427 }
428}
429
430fn authors(comments: &[ReviewComment]) -> String {
432 let mut seen: Vec<&str> = Vec::new();
433 for c in comments {
434 if !seen.contains(&c.author.as_str()) {
435 seen.push(&c.author);
436 }
437 }
438 seen.join(", ")
439}
440
441pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445 vec![
446 "pr".to_owned(),
447 "merge".to_owned(),
448 number.to_string(),
449 "--squash".to_owned(),
450 "--delete-branch".to_owned(),
451 "--subject".to_owned(),
452 subject.to_owned(),
453 ]
454}
455
456pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462 let mut argv = merge_argv(number, subject);
463 argv.push("--match-head-commit".to_owned());
464 argv.push(head.to_owned());
465 argv
466}
467
468pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473 ["pr", "merge", &number.to_string(), "--disable-auto"]
474 .map(str::to_owned)
475 .to_vec()
476}
477
478fn direct_merge_is_safe(
484 fresh: Option<&Seen>,
485 approved_head: &str,
486 shown: &BTreeSet<String>,
487 round: usize,
488 budget: usize,
489 waited: Duration,
490) -> bool {
491 let Some(fresh) = fresh else {
492 return false;
493 };
494 if fresh.pr.state != PrLifecycle::Open {
495 return false;
496 }
497 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498 return false;
499 };
500 if !bound.eq_ignore_ascii_case(approved_head) {
501 return false;
502 }
503 let mut pr = fresh.pr.clone();
504 pr.review_comments.retain(|c| !shown.contains(&c.body));
505 decide(&pr, round, budget, waited) == Step::Merge
506}
507
508fn waiting_on(
515 merge_state: &str,
516 contexts: &[CheckInfo],
517 required_set: Option<&BTreeSet<String>>,
518) -> String {
519 let state = if merge_state.is_empty() {
520 "unknown"
521 } else {
522 merge_state
523 };
524 let tag = |c: &CheckInfo| match c.verdict {
525 Verdict::Fail => "failed",
526 _ => "pending",
527 };
528 let unsettled: Vec<&CheckInfo> = contexts
529 .iter()
530 .filter(|c| c.verdict != Verdict::Pass)
531 .collect();
532 let required: Vec<String> = unsettled
533 .iter()
534 .filter(|c| c.required == Some(true))
535 .map(|c| format!("{} ({})", c.label, tag(c)))
536 .collect();
537 let unknown: Vec<String> = unsettled
538 .iter()
539 .filter(|c| c.required.is_none())
540 .map(|c| format!("{} ({})", c.label, tag(c)))
541 .collect();
542 let never: Vec<&str> = required_set
546 .map(|set| {
547 set.iter()
548 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549 .map(String::as_str)
550 .collect()
551 })
552 .unwrap_or_default();
553 let mut out = format!("merge state: {state}");
554 if !never.is_empty() {
555 let _ = write!(
556 out,
557 "; required checks never reported: {}",
558 never.join(", ")
559 );
560 }
561 if !required.is_empty() {
562 let _ = write!(
563 out,
564 "; required checks not passing: {}",
565 required.join(", ")
566 );
567 }
568 if !unknown.is_empty() {
569 let _ = write!(
570 out,
571 "; whether these are required could not be read, so they may be: {}",
572 unknown.join(", ")
573 );
574 }
575 if required.is_empty() && unknown.is_empty() && never.is_empty() {
576 if required_set.is_some() {
577 out.push_str(
578 "; no required check is pending, failing or unreported, so GitHub is probably \
579 waiting for a review or another branch rule",
580 );
581 } else {
582 out.push_str(
583 "; the required check list could not be read, so a required check that was \
584 never reported cannot be ruled out",
585 );
586 }
587 }
588 out
589}
590
591pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598 let title = pr_title.trim();
599 if !title.is_empty() && !title.starts_with("magi: candidate") {
600 return title.to_owned();
601 }
602 let first = instruction
603 .lines()
604 .map(str::trim)
605 .find(|l| !l.is_empty())
606 .unwrap_or("magi: land the winning candidate");
607 first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610pub const APPROVE: &str = "merge";
612
613pub const HOLD: &str = "hold";
615
616pub fn unhedged(message: &str, quote: &str) -> bool {
624 let quote = quote.trim();
625 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
626}
627
628fn hedged(text: &str) -> bool {
632 const WORDS: &[&str] = &[
633 "maybe",
634 "probably",
635 "perhaps",
636 "might",
637 "if",
638 "unless",
639 "not",
640 "no",
641 "nope",
642 "stop",
643 "dont",
644 "abort",
645 "revert",
646 "undo",
647 "never",
648 "wait",
649 "hold",
650 "cancel",
651 "but",
652 "think",
653 "guess",
654 "suppose",
655 "unsure",
656 "yet",
657 "except",
658 "only",
659 "cannot",
660 "should",
661 "once",
662 "provided",
663 "providing",
664 "when",
665 "whenever",
666 "after",
667 "until",
668 "before",
669 "assuming",
670 "given",
671 "while",
672 "whether",
673 "depending",
674 "pending",
675 ];
676 const JA: &[&str] = &[
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 "後で",
709 ];
710 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
711 return true;
712 }
713 text.to_lowercase()
714 .replace('\u{2019}', "'")
715 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
716 .filter(|w| !w.is_empty())
717 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
718}
719
720fn retracts(message: &str) -> bool {
723 let lower = message.to_lowercase();
724 [
725 "やっぱ",
726 "待って",
727 "撤回",
728 "never mind",
729 "actually",
730 "on second thought",
731 ]
732 .iter()
733 .any(|w| lower.contains(w))
734 || lower
735 .split(|c: char| !c.is_ascii_alphabetic())
736 .any(|w| w == "wait")
737}
738
739pub const APPROVAL_NODE: &str = "land-approval";
745
746pub const DIFF_MAX_LINES: usize = 400;
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757pub enum Approval {
758 Merge,
760 Hold,
762}
763
764pub fn approval(answer: Option<&str>) -> Approval {
772 match answer {
773 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
774 _ => Approval::Hold,
775 }
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
780enum ApprovalGate {
781 Approved,
783 Held,
786 Pending,
788}
789
790fn esc(s: &str) -> String {
800 let mut out = String::with_capacity(s.len());
801 for c in s.chars() {
802 match c {
803 '&' => out.push_str("&"),
804 '<' => out.push_str("<"),
805 '>' => out.push_str(">"),
806 '"' => out.push_str("""),
807 '\'' => out.push_str("'"),
808 _ => out.push(c),
809 }
810 }
811 out
812}
813
814#[derive(Debug, Clone, PartialEq, Eq)]
816struct StatRow {
817 path: String,
818 added: Option<u64>,
820 removed: Option<u64>,
821}
822
823impl StatRow {
824 fn churn(&self) -> u64 {
827 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
828 }
829}
830
831fn parse_numstat(numstat: &str) -> Vec<StatRow> {
837 let mut rows: Vec<StatRow> = numstat
838 .lines()
839 .filter_map(|line| {
840 let mut parts = line.splitn(3, '\t');
841 let added = parts.next()?.trim();
842 let removed = parts.next()?.trim();
843 let path = parts.next()?.trim();
844 if path.is_empty() {
845 return None;
846 }
847 Some(StatRow {
848 path: path.to_owned(),
849 added: added.parse().ok(),
850 removed: removed.parse().ok(),
851 })
852 })
853 .collect();
854 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
857 rows
858}
859
860fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
869 if line.starts_with("+++") || line.starts_with("---") {
870 (" ", "color:#57606a;font-weight:600", line)
871 } else if let Some(body) = line.strip_prefix('+') {
872 ("+", "background:#e6ffec;color:#0a3622", body)
873 } else if let Some(body) = line.strip_prefix('-') {
874 ("-", "background:#ffebe9;color:#5c1a17", body)
875 } else if line.starts_with("@@") {
876 ("~", "background:#eef2ff;color:#3730a3", line)
877 } else if let Some(body) = line.strip_prefix(' ') {
878 (" ", "", body)
879 } else {
880 (" ", "color:#57606a;font-weight:600", line)
881 }
882}
883
884struct Words {
893 html_lang: &'static str,
894 task: &'static str,
895 what_changed: &'static str,
896 review_verdict: &'static str,
897 reviewer: &'static str,
898 reviewer_no_answer: &'static str,
899 checks: &'static str,
900 nothing_failing: &'static str,
901 files_changed: &'static str,
902 commits: &'static str,
903 no_commits: &'static str,
904 comments: &'static str,
905 no_comments: &'static str,
906 diff: &'static str,
907 truncated: &'static str,
908 lands_as: &'static str,
909}
910
911const EN: Words = Words {
912 html_lang: "en",
913 task: "Task",
914 what_changed: "What changed",
915 review_verdict: "Review verdict",
916 reviewer: "Reviewer",
917 reviewer_no_answer: "produced no answer",
918 checks: "Checks",
919 nothing_failing: "Nothing failing.",
920 files_changed: "file(s) changed",
921 commits: "Commits being squashed",
922 no_commits: "No commit subjects could be read from the branch.",
923 comments: "Review comments",
924 no_comments: "Nothing outstanding at this observation.",
925 diff: "Diff",
926 truncated: "Truncated",
927 lands_as: "They land as one commit titled",
928};
929
930const JA: Words = Words {
931 html_lang: "ja",
932 task: "タスク",
933 what_changed: "変更内容",
934 review_verdict: "レビューの結論",
935 reviewer: "レビュアー",
936 reviewer_no_answer: "回答なし",
937 checks: "チェック",
938 nothing_failing: "失敗しているものはありません。",
939 files_changed: "ファイル変更",
940 commits: "squash されるコミット",
941 no_commits: "ブランチからコミット件名を読めませんでした。",
942 comments: "レビューコメント",
943 no_comments: "この時点で未対応のものはありません。",
944 diff: "差分",
945 truncated: "省略",
946 lands_as: "これらは次の件名の1コミットとして入ります:",
947};
948
949impl Words {
950 fn lands_as_tail(&self) -> &'static str {
954 if self.html_lang == "ja" {
955 "。この件名も承認の対象です。"
956 } else {
957 ", which you are approving too."
958 }
959 }
960
961 fn approval_summary(&self, number: u64, subject: &str) -> String {
963 if self.html_lang == "ja" {
964 format!("プルリクエスト #{number} をマージ: {subject}")
965 } else {
966 format!("merge pull request #{number}: {subject}")
967 }
968 }
969
970 fn approval_detail(
972 &self,
973 url: &str,
974 base: &str,
975 subject: &str,
976 contested: Option<&ContestedHandoff>,
977 ) -> String {
978 let body = if self.html_lang == "ja" {
979 format!(
980 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
981 できる状態です。差分の要約・パッチ・squash されるコミットは\
982 下のパネルにあります。"
983 )
984 } else {
985 format!(
986 "{url} is green and ready to squash into `{base}` as `{subject}`. \
987 The panel holds the diffstat, the patch and the commits being squashed."
988 )
989 };
990 match contested {
991 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
992 None => body,
993 }
994 }
995
996 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
999 const SHOWN: usize = 5;
1000 const TITLE_CHARS: usize = 100;
1001 let ja = self.html_lang == "ja";
1002 let mut out = if ja {
1003 format!(
1004 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1005 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1006 )
1007 } else {
1008 format!(
1009 "{url} is held for approval although merge approvals are off: the \
1010 review ended with blocking findings still open and a reviewer \
1011 voting reject.\n"
1012 )
1013 };
1014 for f in c.findings.iter().take(SHOWN) {
1015 let at = match (&f.file, f.line) {
1016 (Some(file), Some(line)) => format!("{file}:{line}"),
1017 (Some(file), None) => file.clone(),
1018 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1019 };
1020 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1021 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1022 }
1023 if c.findings.len() > SHOWN {
1024 let more = c.findings.len() - SHOWN;
1025 let _ = writeln!(
1026 out,
1027 "{}",
1028 if ja {
1029 format!("- ほか {more} 件")
1030 } else {
1031 format!("- and {more} more")
1032 }
1033 );
1034 }
1035 let seats: Vec<String> = c
1036 .rejecters
1037 .iter()
1038 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1039 .collect();
1040 let _ = write!(
1041 out,
1042 "{} {}",
1043 if ja {
1044 "却下したレビュアー:"
1045 } else {
1046 "Rejected by reviewer:"
1047 },
1048 seats.join(", ")
1049 );
1050 out
1051 }
1052
1053 fn truncated_note(
1055 &self,
1056 omitted: usize,
1057 total: usize,
1058 shown: usize,
1059 where_: &str,
1060 base: &str,
1061 head: &str,
1062 ) -> String {
1063 if self.html_lang == "ja" {
1064 format!(
1065 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1066 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1067 プルリクエストにあります。"
1068 )
1069 } else {
1070 format!(
1071 "{omitted} of {total} diff lines omitted after the first {shown}. \
1072 The whole patch is in <code>{where_}</code> \
1073 (<code>git diff {base}...{head}</code>) and on the pull request."
1074 )
1075 }
1076 }
1077}
1078
1079fn words(language: &str) -> &'static Words {
1082 if crate::lang::is_japanese(language) {
1083 &JA
1084 } else {
1085 &EN
1086 }
1087}
1088
1089pub fn approval_panel(
1101 state: &RunState,
1102 pr: &PrState,
1103 diffstat: &str,
1104 diff: &str,
1105 commits: &[String],
1106 subject: &str,
1107) -> String {
1108 let rows = parse_numstat(diffstat);
1109 let w = words(&state.config.graph.language);
1110 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1111
1112 let _ = writeln!(
1113 h,
1114 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1115 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1116 w.html_lang
1117 );
1118 let _ = writeln!(
1119 h,
1120 "<title>merge #{} — {}</title>\n</head>",
1121 pr.number,
1122 esc(subject)
1123 );
1124 h.push_str(
1125 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1126 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1127 word-break:break-word\">\n",
1128 );
1129
1130 let _ = writeln!(
1132 h,
1133 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1134 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1135 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1136 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1137 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1138 pr.number,
1139 esc(&state.base_branch),
1140 esc(subject),
1141 esc(&state.id),
1142 esc(&pr.url),
1143 esc(&pr.url),
1144 );
1145
1146 let _ = writeln!(
1149 h,
1150 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1151 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1152 w.task,
1153 esc(&state.instruction)
1154 );
1155
1156 if let Some(summary) = state
1158 .winner()
1159 .map(|c| c.summary.as_str())
1160 .filter(|s| !s.is_empty())
1161 {
1162 let _ = writeln!(
1163 h,
1164 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1165 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1166 w.what_changed,
1167 esc(summary)
1168 );
1169 }
1170
1171 if let Some(round) = state.reviews.last() {
1174 let _ = writeln!(
1175 h,
1176 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1177 w.review_verdict
1178 );
1179 for r in &round.reviews {
1180 let body = match &r.failed {
1187 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1188 None => esc(&r.summary),
1189 };
1190 let _ = writeln!(
1191 h,
1192 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1193 border-radius:6px\">\
1194 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1195 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1196 w.reviewer,
1197 r.reviewer,
1198 esc(&r.agent),
1199 body,
1200 );
1201 }
1202 }
1203
1204 let _ = writeln!(
1205 h,
1206 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1207 w.checks,
1208 esc(pr.checks.as_str())
1209 );
1210 if pr.failing.is_empty() {
1211 let _ = writeln!(
1212 h,
1213 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1214 w.nothing_failing
1215 );
1216 } else {
1217 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1218 for f in &pr.failing {
1219 let _ = writeln!(h, "<li>{}</li>", esc(f));
1220 }
1221 h.push_str("</ul>\n");
1222 }
1223
1224 let _ = writeln!(
1227 h,
1228 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1229 rows.len(),
1230 w.files_changed
1231 );
1232 h.push_str(
1233 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1234 <thead><tr>\
1235 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1236 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1237 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1238 </th></tr></thead>\n<tbody>\n",
1239 );
1240 let mut total_added = 0u64;
1241 let mut total_removed = 0u64;
1242 for r in &rows {
1243 total_added += r.added.unwrap_or(0);
1244 total_removed += r.removed.unwrap_or(0);
1245 let cell = |n: Option<u64>| match n {
1246 Some(n) => n.to_string(),
1247 None => "bin".to_owned(),
1248 };
1249 let _ = writeln!(
1250 h,
1251 "<tr>\
1252 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1253 font-family:ui-monospace,monospace\">{}</td>\
1254 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1255 color:#0a3622\">{}</td>\
1256 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1257 color:#5c1a17\">{}</td></tr>",
1258 esc(&r.path),
1259 cell(r.added),
1260 cell(r.removed),
1261 );
1262 }
1263 let _ = writeln!(
1264 h,
1265 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1266 <td style=\"padding:4px 2px\">total</td>\
1267 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1268 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1269 </tr></tfoot>\n</table>"
1270 );
1271
1272 let _ = writeln!(
1274 h,
1275 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1276 w.commits
1277 );
1278 if commits.is_empty() {
1279 h.push_str(&format!(
1280 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1281 w.no_commits
1282 ));
1283 } else {
1284 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1285 for c in commits {
1286 let _ = writeln!(h, "<li>{}</li>", esc(c));
1287 }
1288 h.push_str("</ol>\n");
1289 }
1290 let _ = writeln!(
1291 h,
1292 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1293 w.lands_as,
1294 esc(subject),
1295 w.lands_as_tail()
1296 );
1297
1298 let _ = writeln!(
1300 h,
1301 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1302 w.comments
1303 );
1304 if pr.review_comments.is_empty() {
1305 h.push_str(&format!(
1306 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1307 w.no_comments
1308 ));
1309 } else {
1310 for c in &pr.review_comments {
1311 let anchor = match (&c.path, c.line) {
1312 (Some(p), Some(l)) => format!("{p}:{l}"),
1313 (Some(p), None) => p.clone(),
1314 _ => "pull request thread".to_owned(),
1315 };
1316 let _ = writeln!(
1317 h,
1318 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1319 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1320 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1321 esc(&c.author),
1322 esc(&anchor),
1323 esc(&tail(&c.body, 800)),
1324 );
1325 }
1326 }
1327
1328 let total = diff.lines().count();
1330 let shown = total.min(DIFF_MAX_LINES);
1331 let _ = writeln!(
1332 h,
1333 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1334 w.diff
1335 );
1336 h.push_str(
1337 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1338 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1339 );
1340 for line in diff.lines().take(shown) {
1341 let (gutter, style, body) = diff_row(line);
1342 let _ = writeln!(
1343 h,
1344 "<div style=\"display:flex;{style}\">\
1345 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1346 border-right:1px solid #d0d7de\">{gutter}</span>\
1347 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1348 esc(body),
1349 );
1350 }
1351 h.push_str("</div>\n");
1352 if total > shown {
1353 let omitted = total - shown;
1354 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1355 let where_ = state.winner().map_or_else(
1356 || state.repo.display().to_string(),
1357 |w| w.worktree.display().to_string(),
1358 );
1359 let _ = writeln!(
1360 h,
1361 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1362 font-size:13px\">{}: {}</p>",
1363 w.truncated,
1364 w.truncated_note(
1365 omitted,
1366 total,
1367 shown,
1368 &esc(&where_),
1369 &esc(&state.base_branch),
1370 &esc(head),
1371 ),
1372 );
1373 }
1374
1375 h.push_str("</body>\n</html>\n");
1376 h
1377}
1378
1379fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1383 if state.config.graph.hold_contested_merge {
1384 state.contested_handoff.clone()
1385 } else {
1386 None
1387 }
1388}
1389
1390pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1399 let mut s = format!(
1400 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1401 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1402 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1403 request open. Silence is a hold: the owner not answering never merges. Only \
1404 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1405 own words, merges. Whether their wording is a clear, unconditional instruction \
1406 is your judgement alone: if it is doubtful, conditional, retracted or a \
1407 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1408 silence are a hold.\n\n\
1409 This brief is a snapshot from when you were attached: check `magi show {run}` \
1410 and `gh pr view` (read-only) before telling the owner anything current. \
1411 You run with permission to write the question record, and what keeps you \
1412 from touching anything else is this brief and your instructions - so do \
1413 not change files, branches or the pull request.",
1414 run = q.run
1415 );
1416 let Some(state) = state else {
1417 s.push_str(
1418 "\n\nThe run's record could not be read, so the pull request, the panel \
1419 summary and any contested findings are not known to you beyond the \
1420 question's own text. Say so to the owner rather than guessing.",
1421 );
1422 return s;
1423 };
1424 if let Some(pr) = &state.pr {
1425 s.push_str(&format!(
1426 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1427 pr.number, pr.url, pr.state
1428 ));
1429 }
1430 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1431 if let Some(w) = state.winner() {
1432 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1433 }
1434 match contested_to_ask(state) {
1435 Some(c) => {
1436 s.push_str(
1437 "\n\nThis question was filed although merge approvals are off, because \
1438 the review hand-off is contested. Open findings:",
1439 );
1440 for f in &c.findings {
1441 let at = match (&f.file, f.line) {
1442 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1443 (Some(file), None) => format!(" ({file})"),
1444 _ => String::new(),
1445 };
1446 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1447 }
1448 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1449 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1450 }
1451 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1452 }
1453 s
1454}
1455
1456async fn approval_gate(
1476 state: &mut RunState,
1477 pr: &PrState,
1478 subject: &str,
1479 contested: Option<&ContestedHandoff>,
1480 head: &str,
1481) -> Result<ApprovalGate> {
1482 let store = ask::Questions::open();
1483 let reusable = state
1488 .land_approval
1489 .as_ref()
1490 .filter(|a| a.head.eq_ignore_ascii_case(head))
1491 .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1492 if reusable.is_none() {
1493 for stale in store
1494 .list()
1495 .into_iter()
1496 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1497 {
1498 let why = "the pull request moved to a different head commit; asked again about it";
1499 if let Err(e) = store.update(&stale.id, |q| {
1500 q.abandon(why);
1501 Ok(())
1502 }) {
1503 tracing::warn!("could not retire the superseded approval question: {e:#}");
1504 }
1505 }
1506 }
1507
1508 let q = match reusable {
1509 Some(q) => q,
1510 None => {
1511 let worktree = match state.winner() {
1512 Some(w) => w.worktree.clone(),
1513 None => state.repo.clone(),
1514 };
1515 let head = if head.is_empty() {
1518 state
1519 .winner()
1520 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1521 } else {
1522 head.to_owned()
1523 };
1524 let remote = &state.config.merge.remote;
1527 let tracking = format!("{remote}/{}", state.base_branch);
1528 let base = if git::rev_exists(&worktree, &tracking).await {
1529 tracking
1530 } else {
1531 String::new()
1532 };
1533 let range = format!("{base}...{head}");
1534 let numstat = if base.is_empty() {
1538 String::new()
1539 } else {
1540 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1541 .await
1542 .map(|o| o.stdout)
1543 .unwrap_or_default()
1544 };
1545 let diff = if base.is_empty() {
1546 String::new()
1547 } else {
1548 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1549 };
1550 let commits: Vec<String> = if base.is_empty() {
1551 Vec::new()
1552 } else {
1553 git::git_raw(
1554 &worktree,
1555 &[
1556 "log",
1557 "--reverse",
1558 "--format=%s",
1559 &format!("{base}..{head}"),
1560 ],
1561 )
1562 .await
1563 .map(|o| o.stdout)
1564 .unwrap_or_default()
1565 .lines()
1566 .filter(|l| !l.trim().is_empty())
1567 .map(str::to_owned)
1568 .collect()
1569 };
1570
1571 let w = words(&state.config.graph.language);
1572 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1573 let mut fresh = ask::Question::new(
1574 state.id.clone(),
1575 APPROVAL_NODE.to_owned(),
1576 "land".to_owned(),
1577 w.approval_summary(pr.number, subject),
1578 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1579 vec![APPROVE.to_owned(), HOLD.to_owned()],
1580 );
1581 store
1582 .put_panel(&mut fresh, &html, &[])
1583 .context("write the merge approval panel")?;
1584 store
1585 .put(&mut fresh)
1586 .context("file the merge approval question")?;
1587 state.land_approval = Some(LandApproval {
1588 question: fresh.id.clone(),
1589 head: head.clone(),
1590 });
1591 state.event(
1592 "land",
1593 format!("asking for merge approval ({})", fresh.short()),
1594 );
1595 state.save()?;
1596 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1597 tracing::warn!(
1601 "could not notify about merge approval question {}: {e:#} - \
1602 the web UI is the only surface for it now",
1603 fresh.short()
1604 );
1605 }
1606 fresh
1607 }
1608 };
1609
1610 Ok(match q.status {
1611 ask::QuestionStatus::Open => ApprovalGate::Pending,
1612 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1616 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1620 Approval::Merge => ApprovalGate::Approved,
1621 Approval::Hold => ApprovalGate::Held,
1622 },
1623 })
1624}
1625
1626fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1629 let mut failing = Vec::new();
1630 let mut pending = false;
1631 let mut unknown = false;
1632 for check in rollup {
1633 match check.verdict() {
1634 Verdict::Pass => {}
1635 Verdict::Pending => pending = true,
1636 Verdict::Fail => failing.push(check.label()),
1637 Verdict::Unknown => unknown = true,
1638 }
1639 }
1640 let checks = if rollup.is_empty() {
1641 Checks::Unknown
1642 } else if pending {
1643 Checks::Pending
1644 } else if !failing.is_empty() {
1645 Checks::Red
1646 } else if unknown {
1647 Checks::Unknown
1648 } else {
1649 Checks::Green
1650 };
1651 (checks, failing)
1652}
1653
1654pub fn parse_pr(json: &str) -> Result<PrState> {
1657 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1658 let state = match raw.state.to_ascii_uppercase().as_str() {
1659 "OPEN" => PrLifecycle::Open,
1660 "MERGED" => PrLifecycle::Merged,
1661 "CLOSED" => PrLifecycle::Closed,
1662 other => bail!("unknown pull request state `{other}`"),
1663 };
1664
1665 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1666
1667 let mut review_comments = Vec::new();
1668 for r in raw.reviews {
1669 push_if_outstanding(
1670 &mut review_comments,
1671 ReviewComment {
1672 author: r.author.login,
1673 path: None,
1674 line: None,
1675 body: r.body,
1676 },
1677 );
1678 }
1679 for c in raw.comments {
1680 push_if_outstanding(
1681 &mut review_comments,
1682 ReviewComment {
1683 author: c.author.login,
1684 path: None,
1685 line: None,
1686 body: c.body,
1687 },
1688 );
1689 }
1690
1691 Ok(PrState {
1692 url: raw.url,
1693 number: raw.number,
1694 state,
1695 checks,
1696 failing,
1697 review_comments,
1698 blocking: Blocking::of(&raw.merge_state_status),
1699 })
1700}
1701
1702#[derive(Debug, Clone, PartialEq, Eq)]
1704pub(crate) struct CheckView {
1705 pub name: String,
1706 pub verdict: Verdict,
1707 pub run: Option<String>,
1709 pub url: Option<String>,
1710}
1711
1712#[derive(Debug, Clone, PartialEq, Eq)]
1716pub(crate) struct RollupView {
1717 pub url: String,
1718 pub number: u64,
1719 pub state: PrLifecycle,
1720 pub head: String,
1721 pub checks: Vec<CheckView>,
1722}
1723
1724pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1727 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1728 let state = match raw.state.to_ascii_uppercase().as_str() {
1729 "OPEN" => PrLifecycle::Open,
1730 "MERGED" => PrLifecycle::Merged,
1731 "CLOSED" => PrLifecycle::Closed,
1732 other => bail!("unknown pull request state `{other}`"),
1733 };
1734 let checks = raw
1735 .status_check_rollup
1736 .iter()
1737 .map(|c| CheckView {
1738 name: c.label(),
1739 verdict: c.verdict(),
1740 run: c.url().and_then(run_of),
1741 url: c.url().map(str::to_owned),
1742 })
1743 .collect();
1744 Ok(RollupView {
1745 url: raw.url,
1746 number: raw.number,
1747 state,
1748 head: raw.head_ref_oid,
1749 checks,
1750 })
1751}
1752
1753pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1763 let view = gh(
1764 repo,
1765 &[
1766 "pr".to_owned(),
1767 "view".to_owned(),
1768 pr_url.to_owned(),
1769 "--json".to_owned(),
1770 "state".to_owned(),
1771 ],
1772 )
1773 .await?;
1774 if !view.0 {
1775 bail!("gh pr view {pr_url}: {}", view.1);
1776 }
1777 Ok(parse_pr(&view.1)?.state)
1781}
1782
1783#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1787pub struct ExternalMerge {
1788 pub url: String,
1790 pub number: u64,
1792}
1793
1794#[derive(Debug, Deserialize)]
1795#[serde(rename_all = "camelCase")]
1796struct GhMergedPr {
1797 url: String,
1798 number: u64,
1799 merged_at: String,
1800 base_ref_name: String,
1801}
1802
1803fn pick_merged_pr(
1817 json: &str,
1818 base_branch: &str,
1819 created_at: Timestamp,
1820) -> Result<Option<ExternalMerge>> {
1821 let raw: Vec<GhMergedPr> =
1822 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1823 let mut matches: Vec<ExternalMerge> = Vec::new();
1824 for pr in raw {
1825 if pr.base_ref_name != base_branch {
1826 continue;
1827 }
1828 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1829 continue;
1830 };
1831 if merged_at < created_at {
1832 continue;
1833 }
1834 matches.push(ExternalMerge {
1835 url: pr.url,
1836 number: pr.number,
1837 });
1838 }
1839 if matches.len() == 1 {
1840 Ok(matches.pop())
1841 } else {
1842 Ok(None)
1843 }
1844}
1845
1846#[derive(Debug, Clone, PartialEq, Eq)]
1848pub enum OpenPr {
1849 None,
1851 One {
1853 url: String,
1855 title: String,
1857 },
1858 Many(Vec<String>),
1860}
1861
1862#[derive(Debug, Deserialize)]
1863#[serde(rename_all = "camelCase")]
1864struct GhOpenPr {
1865 url: String,
1869 #[serde(default)]
1870 title: String,
1871 base_ref_name: String,
1872 #[serde(default)]
1875 is_cross_repository: bool,
1876}
1877
1878pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1884 let raw: Vec<GhOpenPr> =
1885 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1886 let mut hits: Vec<GhOpenPr> = raw
1887 .into_iter()
1888 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
1889 .collect();
1890 Ok(match hits.len() {
1891 0 => OpenPr::None,
1892 1 => {
1893 let p = hits.remove(0);
1894 OpenPr::One {
1895 url: p.url,
1896 title: p.title,
1897 }
1898 }
1899 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1900 })
1901}
1902
1903pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1907 let (ok, out) = gh(
1908 repo,
1909 &[
1910 "pr".to_owned(),
1911 "list".to_owned(),
1912 "--head".to_owned(),
1913 branch.to_owned(),
1914 "--base".to_owned(),
1915 base.to_owned(),
1916 "--state".to_owned(),
1917 "open".to_owned(),
1918 "--json".to_owned(),
1919 "number,url,title,baseRefName,isCrossRepository".to_owned(),
1920 ],
1921 )
1922 .await?;
1923 if !ok {
1924 bail!("gh pr list failed: {out}");
1925 }
1926 pick_open_pr(&out, base)
1927}
1928
1929#[derive(Debug, Deserialize)]
1930#[serde(rename_all = "camelCase")]
1931struct GhPrHead {
1932 head_ref_name: String,
1933 base_ref_name: String,
1934 state: String,
1935 is_cross_repository: bool,
1938 head_ref_oid: String,
1941}
1942
1943#[derive(Debug, Clone, PartialEq, Eq)]
1945pub struct Refusal {
1946 pub retry: bool,
1949 pub why: String,
1951}
1952
1953impl Refusal {
1954 fn final_(why: String) -> Self {
1955 Self { retry: false, why }
1956 }
1957}
1958
1959pub fn closable(
1968 json: &str,
1969 branch: &str,
1970 base: &str,
1971 verified: &[String],
1972) -> std::result::Result<(), Refusal> {
1973 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1974 retry: true,
1975 why: format!("could not read the pull request ({e})"),
1976 })?;
1977 if pr.head_ref_name != branch {
1978 return Err(Refusal::final_(format!(
1979 "its head is `{}`, not this run's `{branch}`",
1980 pr.head_ref_name
1981 )));
1982 }
1983 if pr.is_cross_repository {
1984 return Err(Refusal::final_("its head lives in a fork".to_owned()));
1985 }
1986 if pr.base_ref_name != base {
1987 return Err(Refusal::final_(format!(
1988 "it targets `{}`, not `{base}`",
1989 pr.base_ref_name
1990 )));
1991 }
1992 if !pr.state.eq_ignore_ascii_case("open") {
1993 return Err(Refusal::final_(format!(
1994 "it is already {}",
1995 pr.state.to_ascii_lowercase()
1996 )));
1997 }
1998 if !verified.contains(&pr.head_ref_oid) {
2002 return Err(Refusal {
2003 retry: true,
2004 why: format!(
2005 "its head {} is not a commit this run checked against the base",
2006 crate::already::short_sha(&pr.head_ref_oid)
2007 ),
2008 });
2009 }
2010 Ok(())
2011}
2012
2013async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2018 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2019 return false;
2020 };
2021 is_forge_url(url.trim())
2022}
2023
2024fn is_forge_url(url: &str) -> bool {
2025 url.contains("://") && !url.starts_with("file://")
2026 || url
2027 .split_once(':')
2028 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2029}
2030
2031fn forge_unavailable(message: &str) -> bool {
2034 message.contains("known GitHub host") || message.contains("spawn gh")
2035}
2036
2037pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2040 let how = match evidence.proof {
2041 crate::already::Proof::PatchId => format!(
2042 "carried by commit {} on `{base}` with the same patch",
2043 evidence.names()
2044 ),
2045 crate::already::Proof::Ancestry => {
2046 format!("already in the history of `{base}` as {}", evidence.names())
2047 }
2048 crate::already::Proof::Tree => format!(
2049 "already part of `{base}` (merging this branch changes nothing at {})",
2050 crate::already::short_sha(&evidence.tip)
2051 ),
2052 };
2053 format!(
2054 "Closing: everything this branch adds is {how}, so there is nothing left to \
2055 land. This pull request was closed automatically after that was verified; \
2056 reopen it if you disagree."
2057 )
2058}
2059
2060pub async fn close_superseded_pr(
2072 state: &mut RunState,
2073 branch: &str,
2074 evidence: &crate::already::Evidence,
2075 verified: &[String],
2076) -> Result<std::result::Result<String, String>> {
2077 let repo = state.repo.clone();
2078 let base = state.base_branch.clone();
2079 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2080 Some(p) => p.url.clone(),
2081 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2086 return Ok(Err(
2087 "the remote is not a forge, so there is no pull request".to_owned(),
2088 ));
2089 }
2090 None => match find_open_pr(&repo, branch, &base).await {
2091 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2092 return Ok(Err(format!("no forge to ask: {e:#}")));
2093 }
2094 Err(e) => return Err(e),
2095 Ok(OpenPr::One { url, .. }) => url,
2096 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2097 Ok(OpenPr::Many(urls)) => {
2098 return Ok(Err(format!(
2099 "{} open pull requests name it; not choosing between them",
2100 urls.len()
2101 )));
2102 }
2103 },
2104 };
2105 let (ok, view) = gh(
2106 &repo,
2107 &[
2108 "pr".to_owned(),
2109 "view".to_owned(),
2110 url.clone(),
2111 "--json".to_owned(),
2112 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2113 ],
2114 )
2115 .await?;
2116 if !ok {
2117 bail!("gh pr view {url} failed: {view}");
2118 }
2119 if let Err(refusal) = closable(&view, branch, &base, verified) {
2120 if refusal.retry {
2123 bail!("left {url} open: {}", refusal.why);
2124 }
2125 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2126 }
2127 let (ok, out) = gh(
2128 &repo,
2129 &[
2130 "pr".to_owned(),
2131 "close".to_owned(),
2132 url.clone(),
2133 "--comment".to_owned(),
2134 superseded_comment(&base, evidence),
2135 ],
2136 )
2137 .await?;
2138 if !ok {
2139 bail!("gh pr close {url} failed: {out}");
2140 }
2141 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2142 p.state = "closed".to_owned();
2143 }
2144 Ok(Ok(url))
2145}
2146
2147pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2151 let (title, _) = crate::github_text::prepare(state, title, "");
2152 edit_pr_title(repo, url, &title).await
2153}
2154
2155pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2158 edit_pr_title(repo, url, title).await
2159}
2160
2161async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2162 let (ok, out) = gh(
2163 repo,
2164 &[
2165 "pr".to_owned(),
2166 "edit".to_owned(),
2167 url.to_owned(),
2168 "--title".to_owned(),
2169 title.to_owned(),
2170 ],
2171 )
2172 .await?;
2173 if !ok {
2174 bail!("gh pr edit failed: {out}");
2175 }
2176 Ok(())
2177}
2178
2179pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2192 let Some(winner) = state.winner() else {
2193 return Ok(None);
2194 };
2195 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2196 let out = gh(
2197 &state.repo,
2198 &[
2199 "pr".to_owned(),
2200 "list".to_owned(),
2201 "--head".to_owned(),
2202 branch.clone(),
2203 "--state".to_owned(),
2204 "merged".to_owned(),
2205 "--json".to_owned(),
2206 "url,number,mergedAt,baseRefName".to_owned(),
2207 ],
2208 )
2209 .await?;
2210 if !out.0 {
2211 bail!("gh pr list --head {branch}: {}", out.1);
2212 }
2213 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2214}
2215
2216pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2228 let out = tokio::process::Command::new("git")
2229 .args(["merge-base", "--is-ancestor", branch, base_branch])
2230 .current_dir(repo)
2231 .quiet()
2232 .stdin(std::process::Stdio::null())
2233 .output()
2234 .await
2235 .context("spawn git merge-base --is-ancestor")?;
2236 Ok(out.status.success())
2237}
2238
2239fn forge_slug(url: &str) -> Option<(String, &str)> {
2247 let rest = url.rsplit("://").next()?;
2248 let (host, path) = rest.split_once('/')?;
2249 if host.is_empty() {
2250 return None;
2251 }
2252 Some((host.to_ascii_lowercase(), path))
2253}
2254
2255pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2263 let (host, path) = forge_slug(url)?;
2264 let mut segments = path.split('/');
2265 let owner = segments.next()?;
2266 let repo = segments.next()?;
2267 let kind = segments.next()?;
2268 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2269 return None;
2270 }
2271 Some(format!("{host}/{owner}/{repo}"))
2272}
2273
2274fn slug_of_repo_url(url: &str) -> Option<String> {
2278 let (host, path) = forge_slug(url)?;
2279 let mut segments = path.split('/');
2280 let owner = segments.next()?;
2281 let repo = segments.next()?;
2282 if owner.is_empty() || repo.is_empty() {
2283 return None;
2284 }
2285 Some(format!("{host}/{owner}/{repo}"))
2286}
2287
2288pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2309 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2310 return Ok(());
2311 }
2312 bail!(
2313 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2314 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2315 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2316 verify by hand before treating this as a false positive)"
2317 );
2318}
2319
2320async fn repo_slug(repo: &Path) -> Result<String> {
2331 let out = gh(
2332 repo,
2333 &[
2334 "repo".to_owned(),
2335 "view".to_owned(),
2336 "--json".to_owned(),
2337 "url".to_owned(),
2338 ],
2339 )
2340 .await?;
2341 if !out.0 {
2342 bail!("gh repo view --json url: {}", out.1);
2343 }
2344 #[derive(Debug, Deserialize)]
2345 struct GhRepo {
2346 url: String,
2347 }
2348 let parsed: GhRepo = serde_json::from_str(&out.1)
2349 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2350 slug_of_repo_url(&parsed.url)
2351 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2352}
2353
2354pub async fn correct_manual_merge(
2402 state: &mut RunState,
2403 url: &str,
2404) -> Result<(RunStatus, RunStatus)> {
2405 let Some(pr_slug) = slug_of_pr_url(url) else {
2406 bail!(
2407 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2408 this pull request belongs to"
2409 );
2410 };
2411 let run_slug = repo_slug(&state.repo).await?;
2412 ensure_same_repo(&run_slug, &pr_slug)?;
2413 correct_merge(state, url).await
2414}
2415
2416pub(crate) async fn correct_confirmed_external_merge(
2427 state: &mut RunState,
2428 url: &str,
2429) -> Result<(RunStatus, RunStatus)> {
2430 correct_merge(state, url).await
2431}
2432
2433fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2435 let Some(pr) = a.pr.as_ref() else {
2436 return false;
2437 };
2438 if !url.is_empty()
2439 && pr
2440 .url
2441 .trim_end_matches('/')
2442 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2443 {
2444 return true;
2445 }
2446 number > 0
2447 && pr.number == number
2448 && match (a.repo.canonicalize(), repo.canonicalize()) {
2449 (Ok(x), Ok(y)) => x == y,
2450 _ => a.repo == repo,
2451 }
2452}
2453
2454fn rewrite_open_prs(
2467 home: &Path,
2468 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2469) -> usize {
2470 let now = Timestamp::now();
2471 let mut changed = 0;
2472 for id in crate::run::list_ids_in(&home.join("runs")) {
2473 let path = home.join("runs").join(&id).join("run.json");
2474 let Ok(body) = std::fs::read_to_string(&path) else {
2475 continue;
2476 };
2477 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2478 continue;
2479 };
2480 if !state.status.done()
2481 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2482 || crate::daemon::is_working_on(home, &id, now)
2483 {
2484 continue;
2485 }
2486 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2487 continue;
2488 };
2489 if let Some(pr) = state.pr.as_mut() {
2490 pr.state = to.as_str().to_owned();
2491 }
2492 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2493 state.event(
2494 "land",
2495 format!("recorded {url} as {}: another run settled it", to.as_str()),
2496 );
2497 match state.save_under(home) {
2498 Ok(()) => changed += 1,
2499 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2500 }
2501 }
2502 changed
2503}
2504
2505pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2510 if to == PrLifecycle::Open {
2511 return;
2512 }
2513 let Some(home) = crate::run::try_home() else {
2514 return;
2515 };
2516 write_pr_state_through_in(&home, run, to);
2517}
2518
2519pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2520 let Some(pr) = run.pr.as_ref() else {
2521 return 0;
2522 };
2523 let (url, number) = (pr.url.clone(), pr.number);
2524 rewrite_open_prs(home, &mut |other| {
2525 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2526 })
2527}
2528
2529pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2532 let now = Timestamp::now();
2533 let mut out = Vec::new();
2534 for id in crate::run::list_ids_in(&home.join("runs")) {
2535 let path = home.join("runs").join(&id).join("run.json");
2536 let Ok(body) = std::fs::read_to_string(&path) else {
2537 continue;
2538 };
2539 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2540 continue;
2541 };
2542 if let Some(pr) = state.pr.as_ref()
2543 && state.status.done()
2544 && pr.state == "open"
2545 && !pr.url.is_empty()
2546 && !crate::daemon::is_working_on(home, &id, now)
2547 {
2548 out.push((id, state.repo.clone(), pr.url.clone()));
2549 }
2550 }
2551 out
2552}
2553
2554pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2557 rewrite_open_prs(home, &mut |s| {
2558 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2559 })
2560}
2561
2562pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2569 let mut known = BTreeMap::new();
2570 let mut failed = 0;
2571 let mut seen = BTreeSet::new();
2572 for (_, repo, url) in stale_open_prs(home) {
2573 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2574 continue;
2575 }
2576 match lifecycle(&repo, &url).await {
2577 Ok(state) => {
2578 known.insert(url, state);
2579 }
2580 Err(e) => {
2581 tracing::warn!("repair pr state of {url}: {e:#}");
2582 failed += 1;
2583 }
2584 }
2585 }
2586 (apply_pr_states(home, &known), failed)
2587}
2588
2589async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2590 match lifecycle(&state.repo, url).await? {
2591 PrLifecycle::Merged => {}
2592 other => bail!(
2593 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2594 other.as_str(),
2595 state.id
2596 ),
2597 }
2598 let before = state.status;
2599 if let Err(e) = land(state, url).await {
2600 state.status = RunStatus::Blocked;
2607 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2608 state.save()?;
2609 return Err(e).context(format!("confirming the merge of {url}"));
2610 }
2611 state.event(
2612 "fold",
2613 "operator recorded this pull request as a manual merge; this run never \
2614 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2615 bump this change might warrant has to be filed by hand",
2616 );
2617 if state.status == RunStatus::Merged {
2620 crate::followup::after_merge(state, url).await;
2621 }
2622 state.save()?;
2623 Ok((before, state.status))
2624}
2625
2626pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2633 let raw: Vec<GhInline> =
2634 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2635 let mut out = Vec::new();
2636 for c in raw {
2637 push_if_outstanding(
2638 &mut out,
2639 ReviewComment {
2640 author: c.user.login,
2641 path: c.path,
2642 line: c.line,
2643 body: c.body,
2644 },
2645 );
2646 }
2647 Ok(out)
2648}
2649
2650fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2656 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2657 return;
2658 }
2659 if comment.path.is_none() && is_noise(&comment.body) {
2660 return;
2661 }
2662 out.push(comment);
2663}
2664
2665pub fn is_noise(body: &str) -> bool {
2683 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2684 return true;
2685 }
2686 let mut content = false;
2687 for line in strip_blocks(body).lines() {
2688 let line = unquote(line);
2689 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2690 continue;
2691 }
2692 content = true;
2693 break;
2694 }
2695 !content
2696}
2697
2698fn strip_blocks(body: &str) -> String {
2700 let mut out = String::with_capacity(body.len());
2701 let mut rest = body;
2702 loop {
2703 let open = ["<!--", "<details>"]
2704 .iter()
2705 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2706 .min_by_key(|(i, _)| *i);
2707 let Some((at, tag)) = open else {
2708 out.push_str(rest);
2709 return out;
2710 };
2711 out.push_str(&rest[..at]);
2712 let after = &rest[at + tag.len()..];
2713 let close = if tag == "<!--" { "-->" } else { "</details>" };
2714 match after.find(close) {
2715 Some(end) => rest = &after[end + close.len()..],
2716 None => return out,
2718 }
2719 }
2720}
2721
2722fn unquote(line: &str) -> &str {
2724 let mut s = line.trim();
2725 while let Some(rest) = s.strip_prefix('>') {
2726 s = rest.trim_start();
2727 }
2728 s.trim()
2729}
2730
2731fn is_checklist(line: &str) -> bool {
2733 let rest = line
2734 .strip_prefix("- ")
2735 .or_else(|| line.strip_prefix("* "))
2736 .unwrap_or("");
2737 let rest = rest.trim_start();
2738 matches!(
2739 rest.get(..3),
2740 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2741 )
2742}
2743
2744fn is_decoration(line: &str) -> bool {
2746 line.starts_with('#')
2747 || line.starts_with("[!")
2748 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2749}
2750
2751fn is_banner(line: &str) -> bool {
2758 let plain = drop_spans(line, "**", "**");
2759 let plain = if plain.contains("](") {
2760 drop_spans(&plain, "[", ")")
2761 } else {
2762 plain
2763 };
2764 !plain.chars().any(char::is_alphanumeric)
2765}
2766
2767fn drop_spans(s: &str, open: &str, close: &str) -> String {
2771 let mut out = String::with_capacity(s.len());
2772 let mut rest = s;
2773 while let Some(at) = rest.find(open) {
2774 out.push_str(&rest[..at]);
2775 let after = &rest[at + open.len()..];
2776 match after.find(close) {
2777 Some(end) => rest = &after[end + close.len()..],
2778 None => return out,
2779 }
2780 }
2781 out.push_str(rest);
2782 out
2783}
2784
2785fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2803 static LOCKS: std::sync::LazyLock<
2804 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2805 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2806 LOCKS
2807 .lock()
2808 .unwrap_or_else(std::sync::PoisonError::into_inner)
2809 .entry(repo.to_path_buf())
2810 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2811 .clone()
2812}
2813
2814fn repo_label(repo: &Path, pr_url: &str) -> String {
2817 let parts: Vec<&str> = pr_url.split('/').collect();
2818 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2819 && at >= 2
2820 && !parts[at - 1].is_empty()
2821 && !parts[at - 2].is_empty()
2822 {
2823 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2824 }
2825 repo.file_name()
2826 .map(|n| n.to_string_lossy().into_owned())
2827 .unwrap_or_default()
2828}
2829
2830fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2834 (pr.checks == Checks::Red).then(|| {
2835 format!(
2836 "Merged {repo_name} PR #{} with red checks: {} ({})",
2837 pr.number,
2838 if pr.failing.is_empty() {
2839 "(none named)".to_owned()
2840 } else {
2841 pr.failing.join(", ")
2842 },
2843 pr.url
2844 )
2845 })
2846}
2847
2848async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2852 let repo_name = repo_label(&state.repo, &pr.url);
2853 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2854 return;
2855 };
2856 if let Some(rec) = state.pr.as_mut() {
2857 rec.red_at_merge = pr.failing.clone();
2858 }
2859 state.event("land", summary.clone());
2860 crate::notices::raise_with(
2863 crate::notices::merged_red(&state.id, &summary),
2864 &state.config.notify,
2865 );
2866}
2867
2868pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2875 land_with(state, pr_url, &GhForge).await
2876}
2877
2878trait Forge {
2882 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2883 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2884 async fn poll(&self);
2885 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
2887 fn now(&self) -> i64;
2889 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2892 #[allow(clippy::too_many_arguments)]
2893 async fn fix(
2894 &self,
2895 state: &mut RunState,
2896 pr: &PrState,
2897 round: usize,
2898 budget: usize,
2899 reason: &str,
2900 logs: &str,
2901 ) -> Result<Fixed>;
2902}
2903
2904struct GhForge;
2905
2906impl Forge for GhForge {
2907 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2908 observe(repo, pr_url).await
2909 }
2910 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2911 gh(repo, argv).await
2912 }
2913 async fn poll(&self) {
2914 tokio::time::sleep(POLL).await;
2915 }
2916 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
2917 let args = [
2918 "run".to_owned(),
2919 "rerun".to_owned(),
2920 run.to_owned(),
2921 "--failed".to_owned(),
2922 ];
2923 let (ok, out) = gh(repo, &args).await?;
2924 if !ok {
2925 bail!("gh run rerun {run}: {out}");
2926 }
2927 Ok(())
2928 }
2929 fn now(&self) -> i64 {
2930 jiff::Timestamp::now().as_second()
2931 }
2932 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2933 required_contexts_of(repo, base).await
2934 }
2935 async fn fix(
2936 &self,
2937 state: &mut RunState,
2938 pr: &PrState,
2939 round: usize,
2940 budget: usize,
2941 reason: &str,
2942 logs: &str,
2943 ) -> Result<Fixed> {
2944 fix_round(state, pr, round, budget, reason, logs).await
2945 }
2946}
2947
2948fn encode_path_segment(s: &str) -> String {
2950 let mut out = String::new();
2951 for b in s.bytes() {
2952 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2953 out.push(b as char);
2954 } else {
2955 let _ = write!(out, "%{b:02X}");
2956 }
2957 }
2958 out
2959}
2960
2961async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2966 let enc = encode_path_segment(base);
2967 let mut all = BTreeSet::new();
2968 let classic = gh(
2971 repo,
2972 &[
2973 "api".to_owned(),
2974 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2975 ],
2976 )
2977 .await
2978 .ok()?;
2979 if classic.0 {
2980 all.extend(parse_classic_required(&classic.1)?);
2981 } else if !classic.1.contains("404") {
2982 return None;
2983 }
2984 let rules = gh(
2985 repo,
2986 &[
2987 "api".to_owned(),
2988 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2989 ],
2990 )
2991 .await
2992 .ok()?;
2993 if !rules.0 {
2994 return None;
2995 }
2996 all.extend(parse_ruleset_required(&rules.1)?);
2997 Some(all)
2998}
2999
3000fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3003 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3004 let mut out = BTreeSet::new();
3005 for c in v.get("contexts")?.as_array()? {
3006 out.insert(c.as_str()?.to_owned());
3007 }
3008 for c in v
3009 .get("checks")
3010 .and_then(|c| c.as_array())
3011 .into_iter()
3012 .flatten()
3013 {
3014 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3015 out.insert(name.to_owned());
3016 }
3017 }
3018 Some(out)
3019}
3020
3021fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3023 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3024 let mut out = BTreeSet::new();
3025 for rule in v.as_array()? {
3026 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3027 continue;
3028 }
3029 let checks = rule
3030 .pointer("/parameters/required_status_checks")?
3031 .as_array()?;
3032 for c in checks {
3033 out.insert(c.get("context")?.as_str()?.to_owned());
3034 }
3035 }
3036 Some(out)
3037}
3038
3039fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3047 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3048}
3049
3050fn bound_head<'a>(
3060 seen_head: &'a str,
3061 rollup_head: &str,
3062 awaiting: Option<&str>,
3063) -> Option<&'a str> {
3064 if seen_head.is_empty()
3065 || awaiting_new_head(awaiting, seen_head)
3066 || !rollup_head.eq_ignore_ascii_case(seen_head)
3067 {
3068 return None;
3069 }
3070 Some(seen_head)
3071}
3072
3073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3075enum Refused {
3076 Pending,
3078 Recheck,
3082 Final,
3085}
3086
3087fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3090 let Some(after) = after else {
3091 return Refused::Pending;
3093 };
3094 if after.pr.state != PrLifecycle::Open {
3095 return Refused::Final;
3096 }
3097 if !after.head.eq_ignore_ascii_case(observed_head) {
3100 return Refused::Pending;
3101 }
3102 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3104 return Refused::Pending;
3105 }
3106 let state = after.merge_state.to_ascii_uppercase();
3107 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3108 || state.is_empty()
3109 || state == "UNKNOWN"
3110 {
3111 return Refused::Pending;
3112 }
3113 if rechecked {
3114 Refused::Final
3115 } else {
3116 Refused::Recheck
3117 }
3118}
3119
3120async fn disarm<F: Forge>(
3125 forge: &F,
3126 state: &mut RunState,
3127 repo: &Path,
3128 number: u64,
3129) -> std::result::Result<(), String> {
3130 let argv = disable_automerge_argv(number);
3131 let out = {
3132 let merge_lock = repo_merge_lock(repo);
3133 let _merge_slot = merge_lock.lock().await;
3134 forge.merge(repo, &argv).await
3135 };
3136 match out {
3137 Ok((true, _)) => {
3138 state.land_armed_head = None;
3139 state.event("land", "auto-merge disabled");
3140 state.save().map_err(|e| format!("{e:#}"))?;
3141 Ok(())
3142 }
3143 Ok((false, msg)) => Err(msg),
3144 Err(e) => Err(format!("{e:#}")),
3145 }
3146}
3147
3148async fn stop_disarmed<F: Forge>(
3152 forge: &F,
3153 state: &mut RunState,
3154 repo: &Path,
3155 pr: &PrState,
3156 why: &str,
3157) -> Result<()> {
3158 if state.land_armed_head.is_none() {
3159 return stop(state, repo, pr, why).await;
3160 }
3161 match disarm(forge, state, repo, pr.number).await {
3162 Ok(()) => stop(state, repo, pr, why).await,
3163 Err(e) => {
3164 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3165 stop(state, repo, pr, &why).await
3166 }
3167 }
3168}
3169
3170async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3171 crate::pr_head::ensure_owner(state)?;
3172 let repo = state.repo.clone();
3173 let budget = state.config.graph.land_rounds;
3174 let mut round = 0usize;
3175 let mut rebases = 0usize;
3178 let mut waited = Duration::ZERO;
3179 let mut shown: BTreeSet<String> = BTreeSet::new();
3184 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3186 let mut rechecked = false;
3188
3189 state.status = RunStatus::Landing;
3197 state.event("land", format!("watching {pr_url}"));
3198 state.save()?;
3199
3200 let mut resumed_armed = state.land_armed_head.is_some();
3204
3205 loop {
3206 let seen = forge.view(&repo, pr_url).await?;
3207 let mut pr = seen.pr.clone();
3208 pr.review_comments.retain(|c| !shown.contains(&c.body));
3209 state.pr = Some(crate::run::PrRecord {
3210 url: pr.url.clone(),
3211 number: pr.number,
3212 state: pr.state.as_str().to_owned(),
3213 checks: pr.checks.as_str().to_owned(),
3214 round,
3215 rounds: budget,
3216 red_at_merge: Vec::new(),
3217 });
3218 state.save()?;
3219
3220 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3221 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3226 let why = format!(
3227 "a previous pass may have armed auto-merge and it could not be disabled \
3228 on resume: {e}"
3229 );
3230 stop(state, &repo, &pr, &why).await?;
3231 return Ok(pr);
3232 }
3233 }
3234
3235 if pr.state == PrLifecycle::Open
3240 && !seen.head.is_empty()
3241 && state
3242 .land_armed_head
3243 .as_deref()
3244 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3245 {
3246 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3247 let why = format!(
3248 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3249 );
3250 stop(state, &repo, &pr, &why).await?;
3251 return Ok(pr);
3252 }
3253 }
3254
3255 if pr.state == PrLifecycle::Open {
3256 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3257 if waited >= WAIT_CEILING {
3258 let want = awaiting_head.as_deref().unwrap_or_default();
3259 let why = format!(
3260 "the pull request's checks were still not about one readable head after \
3261 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3262 someone may have pushed over it",
3263 WAIT_CEILING.as_secs() / 60,
3264 if want.is_empty() { "any" } else { want },
3265 if seen.head.is_empty() {
3266 "nothing readable"
3267 } else {
3268 &seen.head
3269 },
3270 if seen.rollup_head.is_empty() {
3271 "nothing readable"
3272 } else {
3273 &seen.rollup_head
3274 },
3275 );
3276 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3277 return Ok(pr);
3278 }
3279 waited += POLL;
3280 forge.poll().await;
3281 continue;
3282 }
3283 if awaiting_head.take().is_some() {
3286 waited = Duration::ZERO;
3289 }
3290 }
3291
3292 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3297 let failing = rerunnable(&seen.contexts);
3298 let now = forge.now();
3299 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3300 RerunStep::Rerun(runs) => {
3301 let rec = state.land_reruns.get_or_insert_with(Default::default);
3302 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3303 *rec = LandReruns {
3304 head: seen.head.clone(),
3305 runs: BTreeMap::new(),
3306 };
3307 }
3308 for run in &runs {
3309 rec.runs.insert(run.clone(), now);
3310 }
3311 state.save()?;
3315 let mut any = false;
3316 for run in &runs {
3317 let names = failing
3318 .iter()
3319 .filter(|(_, r)| r.as_deref() == Some(run))
3320 .map(|(l, _)| l.as_str())
3321 .collect::<Vec<_>>()
3322 .join(", ");
3323 match forge.rerun(&repo, run).await {
3324 Ok(()) => {
3325 any = true;
3326 state.event(
3327 "land",
3328 format!("reran failed check(s) {names} (run {run})"),
3329 );
3330 }
3331 Err(e) => state.event(
3333 "land",
3334 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3335 ),
3336 }
3337 }
3338 state.save()?;
3339 if any {
3340 waited = Duration::ZERO;
3341 forge.poll().await;
3342 continue;
3343 }
3344 }
3345 RerunStep::Grace => {
3346 if waited >= WAIT_CEILING {
3347 let why = format!(
3348 "checks were still failing after a rerun and {} minutes",
3349 WAIT_CEILING.as_secs() / 60
3350 );
3351 stop(state, &repo, &pr, &why).await?;
3352 return Ok(pr);
3353 }
3354 waited += POLL;
3355 forge.poll().await;
3356 continue;
3357 }
3358 RerunStep::Fix => {}
3359 }
3360 }
3361
3362 let step = decide(&pr, round, budget, waited);
3363 let armed_here = state
3368 .land_armed_head
3369 .as_deref()
3370 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3371 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3372 if waited >= WAIT_CEILING {
3373 let required = if seen.base.is_empty() {
3374 None
3375 } else {
3376 forge.required_contexts(&repo, &seen.base).await
3377 };
3378 let why = format!(
3379 "auto-merge was armed on {} but the pull request did not merge within {} \
3380 minutes ({})",
3381 seen.head,
3382 WAIT_CEILING.as_secs() / 60,
3383 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3384 );
3385 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3386 return Ok(pr);
3387 }
3388 waited += POLL;
3389 forge.poll().await;
3390 continue;
3391 }
3392 if armed_here && !matches!(step, Step::Done { .. }) {
3393 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3396 let why = format!("auto-merge could not be disabled: {e}");
3397 stop(state, &repo, &pr, &why).await?;
3398 return Ok(pr);
3399 }
3400 }
3401 match step {
3402 Step::Wait => {
3403 if waited >= WAIT_CEILING {
3404 let why = format!(
3405 "checks were still running after {} minutes",
3406 WAIT_CEILING.as_secs() / 60
3407 );
3408 stop(state, &repo, &pr, &why).await?;
3409 return Ok(pr);
3410 }
3411 waited += POLL;
3412 forge.poll().await;
3413 }
3414 Step::Done { merged } => {
3415 if let Some(armed) = state.land_armed_head.take() {
3420 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3421 let msg = format!(
3422 "{} merged on {} but the owner approved {armed}; review what landed",
3423 pr.url, seen.head
3424 );
3425 tracing::warn!("{msg}");
3426 state.event("land", msg);
3427 crate::notices::raise_with(
3431 crate::notices::Notice::warn(
3432 &format!("merged-unapproved-head:{}", state.id),
3433 "A pull request merged on a commit the owner did not approve; \
3434 review what landed",
3435 )
3436 .link(crate::notices::Link::Run {
3437 id: state.id.clone(),
3438 }),
3439 &state.config.notify,
3440 );
3441 }
3442 }
3443 state.status = if merged {
3444 RunStatus::Merged
3445 } else {
3446 RunStatus::Ready
3447 };
3448 let detail = if merged {
3449 format!("{} was merged", pr.url)
3450 } else {
3451 format!("{} was closed without merging", pr.url)
3452 };
3453 state.merge = Some(MergeOutcome {
3454 mode: MergeMode::Pr,
3455 ok: merged,
3456 detail: detail.clone(),
3457 empty: false,
3458 });
3459 state.event("land", detail);
3460 state.save()?;
3461 write_pr_state_through(state, pr.state);
3462 return Ok(pr);
3463 }
3464 Step::Merge => {
3465 let subject = merge_subject(
3466 crate::graph::landing_title(state, &seen.title),
3467 &crate::graph::landing_subject_source(state),
3468 );
3469 let contested = contested_to_ask(state);
3476 if state.config.graph.land_approval || contested.is_some() {
3477 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3478 .await?
3479 {
3480 ApprovalGate::Approved => {}
3481 ApprovalGate::Held => {
3482 stop(
3483 state,
3484 &repo,
3485 &pr,
3486 "the owner did not approve the merge (held or unanswered)",
3487 )
3488 .await?;
3489 return Ok(pr);
3490 }
3491 ApprovalGate::Pending => {
3499 state.parked = true;
3500 state.event(
3501 "land",
3502 "parked awaiting merge approval - resumes once answered",
3503 );
3504 state.save()?;
3505 return Ok(pr);
3506 }
3507 }
3508 }
3509 let observed_head = seen.head.clone();
3520 {
3523 let fresh = forge.view(&repo, pr_url).await.ok();
3524 if !direct_merge_is_safe(
3525 fresh.as_ref(),
3526 &observed_head,
3527 &shown,
3528 round,
3529 budget,
3530 waited,
3531 ) {
3532 if waited >= WAIT_CEILING {
3533 let why = "the pull request did not settle on the approved head \
3534 before it could be merged";
3535 stop(state, &repo, &pr, why).await?;
3536 return Ok(pr);
3537 }
3538 state.event(
3539 "land",
3540 "the pull request changed before merging; looking again",
3541 );
3542 waited += POLL;
3543 forge.poll().await;
3544 continue;
3545 }
3546 }
3547 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3548 let out = {
3549 let merge_lock = repo_merge_lock(&repo);
3550 let _merge_slot = merge_lock.lock().await;
3551 forge.merge(&repo, &argv).await?
3552 };
3553 if out.0 {
3554 let confirmed = forge
3560 .view(&repo, pr_url)
3561 .await
3562 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3563 if !confirmed {
3564 if waited >= WAIT_CEILING {
3565 let why = "the merge request succeeded but the pull request \
3566 could not be confirmed merged after waiting";
3567 stop(state, &repo, &pr, why).await?;
3568 return Ok(pr);
3569 }
3570 state.event(
3571 "land",
3572 "merge accepted but the pull request is not confirmed merged yet; waiting",
3573 );
3574 state.save()?;
3575 waited += POLL;
3576 forge.poll().await;
3577 continue;
3578 }
3579 pr.state = PrLifecycle::Merged;
3580 state.status = RunStatus::Merged;
3581 state.merge = Some(MergeOutcome {
3582 mode: MergeMode::Pr,
3583 ok: true,
3584 detail: format!("gh {}", argv.join(" ")),
3585 empty: false,
3586 });
3587 if let Some(pr_record) = state.pr.as_mut() {
3592 pr_record.state = pr.state.as_str().to_owned();
3593 }
3594 state.event("land", format!("merged {} as `{subject}`", pr.url));
3595 announce_red_merge(state, &pr).await;
3596 state.save()?;
3597 write_pr_state_through(state, pr.state);
3598 return Ok(pr);
3599 }
3600 let after_seen = forge.view(&repo, pr_url).await.ok();
3601 let after = after_seen.as_ref().map(|s| s.pr.state);
3602 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3603 pr.state = PrLifecycle::Merged;
3604 state.status = RunStatus::Merged;
3605 state.merge = Some(outcome);
3606 if let Some(pr_record) = state.pr.as_mut() {
3607 pr_record.state = pr.state.as_str().to_owned();
3608 }
3609 state.event("land", format!("merged {} as `{subject}`", pr.url));
3610 announce_red_merge(state, &pr).await;
3611 state.save()?;
3612 write_pr_state_through(state, pr.state);
3613 return Ok(pr);
3614 }
3615 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3616 match verdict {
3617 Refused::Final => {
3618 let merge_state = after_seen
3619 .as_ref()
3620 .map(|s| s.merge_state.as_str())
3621 .filter(|m| !m.is_empty())
3622 .unwrap_or("unknown");
3623 let why = format!(
3627 "the merge was refused: {} (merge state: {merge_state})",
3628 out.1
3629 );
3630 stop(state, &repo, &pr, &why).await?;
3631 return Ok(pr);
3632 }
3633 verdict => {
3634 if waited >= WAIT_CEILING {
3639 let why = format!(
3640 "the merge was still refused after {} minutes: {}",
3641 WAIT_CEILING.as_secs() / 60,
3642 out.1
3643 );
3644 stop(state, &repo, &pr, &why).await?;
3645 return Ok(pr);
3646 }
3647 if verdict == Refused::Recheck {
3648 rechecked = true;
3649 }
3650 state.event(
3651 "land",
3652 "merge refused while the branch policy is not satisfied yet; waiting",
3653 );
3654 state.save()?;
3655 waited += POLL;
3656 forge.poll().await;
3657 }
3658 }
3659 }
3660 Step::Rebase => {
3661 if rebases >= budget {
3667 let why = format!(
3668 "the base moved under this branch {budget} time(s) and it still does \
3669 not merge; rebasing again would only race it"
3670 );
3671 stop(state, &repo, &pr, &why).await?;
3672 return Ok(pr);
3673 }
3674 rebases += 1;
3675 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3676 stop(
3677 state,
3678 &repo,
3679 &pr,
3680 "the pull request conflicts and this run has no winning branch to rebase",
3681 )
3682 .await?;
3683 return Ok(pr);
3684 };
3685 let base = state.base_branch.clone();
3686 state.event(
3687 "land",
3688 format!("{} no longer merges; rebasing onto {base}", pr.url),
3689 );
3690 state.save()?;
3691
3692 let remote = state.config.merge.remote.clone();
3696 git::fetch(&repo, &remote, &base).await.ok();
3697 let scratch = state.dir().join("rebase");
3698 let onto = format!("{remote}/{base}");
3699 let rebased =
3700 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3701 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3702 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3703 Err(e) => Err(e),
3704 };
3705 match rebased {
3706 Ok(None) => {
3707 let pushed = {
3708 let merge_lock = repo_merge_lock(&repo);
3709 let _merge_slot = merge_lock.lock().await;
3710 if let Some(head) = &state.pr_head {
3711 git::push_as(
3712 &repo,
3713 &state.config.merge.remote,
3714 &branch,
3715 head,
3716 state
3717 .pr_pushed_head
3718 .as_deref()
3719 .context("missing public head lease")?,
3720 )
3721 .await?
3722 } else {
3723 git::push_pinned(
3724 &repo,
3725 &state.config.merge.remote,
3726 &branch,
3727 &seen.head,
3728 )
3729 .await?
3730 }
3731 };
3732 if !pushed.ok() {
3733 let why = format!(
3734 "rebased {branch} but could not push it: {}",
3735 pushed.stderr.trim()
3736 );
3737 stop(state, &repo, &pr, &why).await?;
3738 return Ok(pr);
3739 }
3740 let head =
3743 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3744 Ok(head) => head,
3745 Err(e) => {
3746 let why = format!(
3747 "rebased and pushed {branch} but could not read the pushed \
3748 commit: {e:#}"
3749 );
3750 stop(state, &repo, &pr, &why).await?;
3751 return Ok(pr);
3752 }
3753 };
3754 crate::graph::refresh_reviewed_commits(state, &branch).await;
3755 state.pr_pushed_head = Some(head.clone());
3756 awaiting_head = Some(head);
3757 rechecked = false;
3758 state.event("land", format!("rebased {branch} onto {base}"));
3759 state.save()?;
3760 waited = Duration::ZERO;
3763 tokio::time::sleep(POLL).await;
3764 }
3765 Ok(Some(conflict)) => {
3768 let why = format!(
3769 "{} conflicts with {base} and the rebase did not apply: {}",
3770 pr.url,
3771 conflict.chars().take(600).collect::<String>()
3772 );
3773 stop(state, &repo, &pr, &why).await?;
3774 return Ok(pr);
3775 }
3776 Err(e) => {
3777 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3778 stop(state, &repo, &pr, &why).await?;
3779 return Ok(pr);
3780 }
3781 }
3782 }
3783 Step::GiveUp { reason } => {
3784 stop(state, &repo, &pr, &reason).await?;
3785 return Ok(pr);
3786 }
3787 Step::Fix { reason } => {
3788 round += 1;
3789 waited = Duration::ZERO;
3790 for c in &pr.review_comments {
3791 shown.insert(c.body.clone());
3792 }
3793 state.event("land", format!("round {round}: {reason}"));
3794 state.save()?;
3795
3796 let logs = failing_logs(&repo, &seen.failing_urls).await;
3797 let was_red = pr.checks == Checks::Red;
3798 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3799 Fixed::Committed { head } => {
3800 state.pr_pushed_head = Some(head.clone());
3803 awaiting_head = Some(head);
3804 rechecked = false;
3805 waited = Duration::ZERO;
3806 forge.poll().await;
3807 }
3808 Fixed::Declined if was_red => {
3809 let why = format!(
3810 "the fixer produced no commit while {} check(s) were failing \
3811 ({}); stopping instead of looping on an unchanged tree",
3812 pr.failing.len(),
3813 pr.failing.join(", ")
3814 );
3815 stop(state, &repo, &pr, &why).await?;
3816 return Ok(pr);
3817 }
3818 Fixed::Declined => state.event(
3823 "land",
3824 format!("round {round}: fixer declined the comments, nothing committed"),
3825 ),
3826 Fixed::Failed(why) => {
3827 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3828 return Ok(pr);
3829 }
3830 }
3831 state.save()?;
3832 }
3833 }
3834 }
3835}
3836
3837#[derive(Clone)]
3841struct Seen {
3842 pr: PrState,
3843 title: String,
3844 failing_urls: Vec<(String, String)>,
3845 head: String,
3847 rollup_head: String,
3850 merge_state: String,
3853 contexts: Vec<CheckInfo>,
3855 base: String,
3857}
3858
3859#[derive(Clone)]
3861struct CheckInfo {
3862 label: String,
3863 verdict: Verdict,
3864 required: Option<bool>,
3865 run: Option<String>,
3867}
3868
3869#[derive(Debug, Clone, PartialEq, Eq)]
3871enum RerunStep {
3872 Rerun(Vec<String>),
3874 Grace,
3876 Fix,
3878}
3879
3880fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
3884 contexts
3885 .iter()
3886 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
3887 .filter(|c| {
3889 c.run.as_ref().is_none_or(|r| {
3890 !contexts.iter().any(|o| {
3891 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
3892 && o.run.as_ref() == Some(r)
3893 })
3894 })
3895 })
3896 .map(|c| (c.label.clone(), c.run.clone()))
3897 .collect()
3898}
3899
3900fn rerun_step(
3902 failing: &[(String, Option<String>)],
3903 record: Option<&LandReruns>,
3904 head: &str,
3905 now: i64,
3906) -> RerunStep {
3907 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
3910 return RerunStep::Fix;
3911 }
3912 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
3914 let mut fresh: Vec<String> = Vec::new();
3915 let mut recent = false;
3916 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
3917 match record.and_then(|r| r.runs.get(run)) {
3918 None => {
3919 if !fresh.iter().any(|f| f == run) {
3920 fresh.push(run.to_owned());
3921 }
3922 }
3923 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
3924 Some(_) => {}
3925 }
3926 }
3927 if !fresh.is_empty() {
3928 RerunStep::Rerun(fresh)
3929 } else if recent {
3930 RerunStep::Grace
3931 } else {
3932 RerunStep::Fix
3933 }
3934}
3935
3936async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3940 let view = gh(
3941 repo,
3942 &[
3943 "pr".to_owned(),
3944 "view".to_owned(),
3945 pr_url.to_owned(),
3946 "--json".to_owned(),
3947 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3948 .to_owned(),
3949 ],
3950 )
3951 .await?;
3952 if !view.0 {
3953 bail!("gh pr view {pr_url}: {}", view.1);
3954 }
3955 let number = parse_pr(&view.1)?.number;
3956 let node = last_commit_node(repo, number).await;
3957 let mut seen = seen_from(&view.1, node.as_deref())?;
3958
3959 let inline = gh(
3960 repo,
3961 &[
3962 "api".to_owned(),
3963 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3964 ],
3965 )
3966 .await?;
3967 if inline.0 {
3968 match parse_inline_comments(&inline.1) {
3969 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3970 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3973 }
3974 } else {
3975 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3976 }
3977 Ok(seen)
3978}
3979
3980fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3991 let mut pr = parse_pr(view_json)?;
3992 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3993
3994 let mut rollup_head = String::new();
3995 let mut failing_urls = Vec::new();
3996 let mut contexts = Vec::new();
3997 let mut checks = Checks::Unknown;
3998 let mut failing = Vec::new();
3999 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4000 (checks, failing) = rollup_verdict(&rollup);
4001 failing_urls = rollup
4002 .iter()
4003 .filter(|c| c.verdict() == Verdict::Fail)
4004 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4005 .collect();
4006 contexts = rollup
4007 .iter()
4008 .map(|c| CheckInfo {
4009 label: c.label(),
4010 verdict: c.verdict(),
4011 required: c.is_required,
4012 run: c.url().and_then(run_of),
4013 })
4014 .collect();
4015 rollup_head = oid;
4016 }
4017 pr.checks = checks;
4018 pr.failing = failing;
4019
4020 Ok(Seen {
4021 pr,
4022 title: raw.title,
4023 failing_urls,
4024 head: raw.head_ref_oid,
4025 rollup_head,
4026 merge_state: raw.merge_state_status,
4027 contexts,
4028 base: raw.base_ref_name,
4029 })
4030}
4031
4032fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4035 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4036 if v.get("errors").is_some_and(|e| !e.is_null()) {
4037 return None;
4038 }
4039 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4040 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4041 let contexts = commit.pointer("/statusCheckRollup/contexts");
4042 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4043 return Some((oid.to_owned(), Vec::new()));
4045 };
4046 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4047 return None;
4048 }
4049 let nodes = contexts.get("nodes")?.as_array()?;
4050 let rollup = nodes
4051 .iter()
4052 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4053 .collect::<Result<Vec<_>, _>>()
4054 .ok()?;
4055 Some((oid.to_owned(), rollup))
4056}
4057
4058async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4061 let out = gh(
4062 repo,
4063 &[
4064 "api".to_owned(),
4065 "graphql".to_owned(),
4066 "-F".to_owned(),
4067 "owner={owner}".to_owned(),
4068 "-F".to_owned(),
4069 "repo={repo}".to_owned(),
4070 "-F".to_owned(),
4071 format!("number={number}"),
4072 "-f".to_owned(),
4073 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4074 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4075 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4076 ... on CheckRun{name status conclusion detailsUrl \
4077 isRequired(pullRequestNumber:$number)} \
4078 ... on StatusContext{context state targetUrl \
4079 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4080 .to_owned(),
4081 ],
4082 )
4083 .await
4084 .ok()?;
4085 out.0.then_some(out.1)
4086}
4087
4088#[doc(hidden)]
4090#[derive(Debug, PartialEq)]
4091pub enum Fixed {
4092 Committed {
4094 head: String,
4096 },
4097 Declined,
4099 Failed(String),
4101}
4102
4103#[doc(hidden)]
4109pub async fn fix_round(
4110 state: &mut RunState,
4111 pr: &PrState,
4112 round: usize,
4113 budget: usize,
4114 reason: &str,
4115 logs: &str,
4116) -> Result<Fixed> {
4117 let winner = state
4118 .winner()
4119 .cloned()
4120 .context("landing needs a winning candidate; none is recorded on this run")?;
4121 let roles = state
4122 .config
4123 .resolve_roles()
4124 .context("resolve the roster for the fix round")?;
4125 let attempts = crate::fixer::attempts(state, &roles, &winner);
4129 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4130
4131 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4132 let artifacts = agent::artifacts_dir(&state.dir());
4133 let prompt = if state.config.cache_dir().is_some() {
4134 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4135 } else {
4136 prompt
4137 };
4138 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4142 let mut last = None;
4146 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4147 let mut seat = seat_of(state, &seat_key, &spec.id);
4148 let stem = if i == 0 {
4149 format!("land-{round}")
4150 } else {
4151 format!("land-{round}-{}", spec.id)
4152 };
4153 let out = agent::invoke(
4154 &spec,
4155 &mut seat,
4156 &Invocation {
4157 cwd: &winner.worktree,
4158 prompt: &prompt,
4159 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4160 allow_write: true,
4161 unsandboxed: false,
4162 sessions: state.config.graph.sessions,
4163 artifacts: &artifacts,
4164 stem: &stem,
4165 run: &state.id,
4166 node: "land",
4167 cache_dir: state.config.cache_dir().as_deref(),
4168 attachments: &[],
4169 writable: &[],
4170 },
4171 )
4172 .await;
4173 state.seats.insert(seat.key.clone(), seat);
4174 if let Some(next) = ids.get(i + 1)
4175 && agent::chain_advances(&out)
4176 {
4177 let (class, why) = crate::fixer::failure_of(&out);
4178 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4179 continue;
4180 }
4181 last = Some(out);
4182 break;
4183 }
4184 let out = last.expect("the fixer chain always has an entry");
4185
4186 match out {
4187 Ok(o) if o.quota_exhausted() => {
4188 return Ok(Fixed::Failed(
4189 "rate limited (quota); the fixer could not run".to_owned(),
4190 ));
4191 }
4192 Ok(o) if !o.usable() => {
4193 return Ok(Fixed::Failed(format!(
4194 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4195 o.exit_code, o.timed_out
4196 )));
4197 }
4198 Ok(_) => {}
4199 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4200 }
4201
4202 if let Ok(r) = git::rescue_commit(
4205 &winner.worktree,
4206 &format!("magi: land round {round} fixes (uncommitted work)"),
4207 )
4208 .await
4209 {
4210 state.note_withheld("land", &r.withheld);
4211 }
4212 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4213 if after == before {
4214 return Ok(Fixed::Declined);
4215 }
4216
4217 let remote = state.config.merge.remote.clone();
4218 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4219 let push = if state.pr_head.is_some() {
4220 let expected = state
4221 .pr_pushed_head
4222 .as_deref()
4223 .context("missing public head lease")?;
4224 git::push_as(
4225 &winner.worktree,
4226 &remote,
4227 &winner.branch,
4228 &public_head,
4229 expected,
4230 )
4231 .await?
4232 } else {
4233 git::push(&winner.worktree, &remote, &winner.branch).await?
4234 };
4235 if !push.ok() {
4236 return Ok(Fixed::Failed(format!(
4237 "pushing {} to {remote} failed: {}",
4238 public_head, push.stderr
4239 )));
4240 }
4241 state.event(
4242 "land",
4243 format!("round {round}: pushed a fix to {public_head}"),
4244 );
4245 state.pr_pushed_head = Some(after.clone());
4246 state.save()?;
4247 Ok(Fixed::Committed { head: after })
4248}
4249
4250pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4252 crate::fixer::seat_for(state, key, agent)
4253}
4254
4255fn fix_prompt(
4257 state: &RunState,
4258 pr: &PrState,
4259 round: usize,
4260 budget: usize,
4261 reason: &str,
4262 logs: &str,
4263) -> String {
4264 let mut s = format!(
4265 "Your patch is open as a pull request and it is not landing. Land round \
4266 {round} of {budget}.\n\n\
4267 Pull request: {}\n\n\
4268 What is holding it: {reason}\n\n\
4269 # The task\n\n{}\n",
4270 pr.url, state.instruction
4271 );
4272
4273 if pr.failing.is_empty() {
4274 s.push_str("\n# Failing checks\n\n(none)\n");
4275 } else {
4276 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4277 if logs.trim().is_empty() {
4278 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4279 } else {
4280 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4281 }
4282 }
4283
4284 if pr.review_comments.is_empty() {
4285 s.push_str("\n# Review comments\n\n(none)\n");
4286 } else {
4287 s.push_str("\n# Review comments\n");
4288 for c in &pr.review_comments {
4289 let where_ = match (&c.path, c.line) {
4290 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4291 (Some(p), None) => format!(" ({p})"),
4292 _ => String::new(),
4293 };
4294 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4295 }
4296 }
4297
4298 s.push_str(
4299 "\n# Rules\n\n\
4300 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4301 failing test; do not silence a lint with an allow attribute; do not \
4302 stretch a timeout to hide a race. If the check is right, the code is \
4303 wrong.\n\
4304 2. Change nothing the checks and the comments did not raise. A \
4305 drive-by refactor turns a one-line fix into a pull request that \
4306 needs reviewing again.\n\
4307 3. If a comment is wrong, say so with a checkable argument and change \
4308 nothing for it. A declined comment with a reason is a correct \
4309 outcome; a change made to appease a reviewer is not.\n\
4310 4. Commit in this worktree. magi pushes to the pull request's branch \
4311 for you; do not push, merge, or close anything yourself.\n\
4312 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4313 # Output\n\n\
4314 Say what you changed and why, and what you declined and why.",
4315 );
4316
4317 let language = &state.config.graph.language;
4318 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4319 let _ = write!(s, "\n\nWrite all prose in {language}.");
4320 }
4321 s.push_str(&crate::prompt::github_english(language));
4323 if let Some(overlay) = state.config.prompts.overlay("fix") {
4324 let _ = write!(s, "\n\n{overlay}");
4325 }
4326 s
4327}
4328
4329async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4332 let mut out = String::new();
4333 for (name, url) in failing.iter().take(MAX_LOGS) {
4334 let args = match (job_of(url), run_of(url)) {
4335 (Some(job), _) => vec![
4336 "run".to_owned(),
4337 "view".to_owned(),
4338 "--log-failed".to_owned(),
4339 "--job".to_owned(),
4340 job,
4341 ],
4342 (None, Some(run)) => vec![
4343 "run".to_owned(),
4344 "view".to_owned(),
4345 run,
4346 "--log-failed".to_owned(),
4347 ],
4348 (None, None) => continue,
4350 };
4351 let (ok, body) = match gh(repo, &args).await {
4352 Ok(v) => v,
4353 Err(e) => (false, format!("{e:#}")),
4354 };
4355 if !ok && body.trim().is_empty() {
4356 continue;
4357 }
4358 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4359 }
4360 out
4361}
4362
4363fn job_of(details_url: &str) -> Option<String> {
4366 let after = details_url.split("/job/").nth(1)?;
4367 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4368 (!id.is_empty()).then_some(id)
4369}
4370
4371pub(crate) fn run_of(details_url: &str) -> Option<String> {
4373 let after = details_url.split("/actions/runs/").nth(1)?;
4374 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4375 (!id.is_empty()).then_some(id)
4376}
4377
4378fn stop_comment(run_id: &str, why: &str) -> String {
4382 format!(
4383 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4384 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4385 )
4386}
4387
4388async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4393 let body = stop_comment(&state.id, why);
4394 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4395 if !body.contains(MARKER) {
4396 body = format!("{MARKER}\n{body}");
4397 }
4398 let posted = gh(
4399 repo,
4400 &[
4401 "pr".to_owned(),
4402 "comment".to_owned(),
4403 pr.number.to_string(),
4404 "--body".to_owned(),
4405 body,
4406 ],
4407 )
4408 .await;
4409 match posted {
4410 Ok((true, _)) => {}
4411 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4412 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4413 }
4414 state.status = RunStatus::Blocked;
4415 state.merge = Some(MergeOutcome {
4416 mode: MergeMode::Pr,
4417 ok: false,
4418 detail: why.to_owned(),
4419 empty: false,
4420 });
4421 state.event("land", format!("stopped: {why}"));
4422 state.save()?;
4423 Ok(())
4424}
4425
4426pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4442 let out = tokio::process::Command::new("gh")
4443 .args(args)
4444 .current_dir(cwd)
4445 .env_remove("GH_REPO")
4446 .quiet()
4447 .stdin(std::process::Stdio::null())
4448 .output()
4449 .await
4450 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4451 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4452 let err = String::from_utf8_lossy(&out.stderr);
4453 if body.trim().is_empty() {
4454 body = err.into_owned();
4455 } else if !err.trim().is_empty() {
4456 body.push_str(&err);
4457 }
4458 Ok((out.status.success(), body.trim().to_owned()))
4459}
4460
4461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4463pub(crate) enum Verdict {
4464 Pass,
4465 Fail,
4466 Pending,
4467 Unknown,
4468}
4469
4470#[derive(Debug, Deserialize)]
4471#[serde(rename_all = "camelCase")]
4472struct GhPr {
4473 #[serde(default)]
4474 url: String,
4475 #[serde(default)]
4476 number: u64,
4477 #[serde(default)]
4478 state: String,
4479 #[serde(default)]
4480 title: String,
4481 #[serde(default)]
4482 status_check_rollup: Vec<GhCheck>,
4483 #[serde(default)]
4490 merge_state_status: String,
4491 #[serde(default)]
4495 head_ref_oid: String,
4496 #[serde(default)]
4497 base_ref_name: String,
4498 #[serde(default)]
4499 reviews: Vec<GhReview>,
4500 #[serde(default)]
4501 comments: Vec<GhComment>,
4502}
4503
4504#[derive(Debug, Deserialize)]
4509#[serde(rename_all = "camelCase")]
4510struct GhCheck {
4511 #[serde(default)]
4512 name: Option<String>,
4513 #[serde(default)]
4514 context: Option<String>,
4515 #[serde(default)]
4516 status: Option<String>,
4517 #[serde(default)]
4518 conclusion: Option<String>,
4519 #[serde(default)]
4520 state: Option<String>,
4521 #[serde(default)]
4522 details_url: Option<String>,
4523 #[serde(default)]
4524 target_url: Option<String>,
4525 #[serde(default)]
4528 is_required: Option<bool>,
4529}
4530
4531impl GhCheck {
4532 fn label(&self) -> String {
4534 self.name
4535 .clone()
4536 .or_else(|| self.context.clone())
4537 .unwrap_or_else(|| "(unnamed check)".to_owned())
4538 }
4539
4540 fn url(&self) -> Option<&str> {
4542 self.details_url
4543 .as_deref()
4544 .or(self.target_url.as_deref())
4545 .filter(|u| !u.is_empty())
4546 }
4547
4548 fn verdict(&self) -> Verdict {
4556 if let Some(status) = self.status.as_deref() {
4557 if !status.eq_ignore_ascii_case("COMPLETED") {
4558 return Verdict::Pending;
4559 }
4560 }
4561 let outcome = self
4562 .conclusion
4563 .as_deref()
4564 .or(self.state.as_deref())
4565 .unwrap_or("");
4566 match outcome.to_ascii_uppercase().as_str() {
4567 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4568 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4569 | "ACTION_REQUIRED" => Verdict::Fail,
4570 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4571 Verdict::Pending
4572 }
4573 _ => Verdict::Unknown,
4574 }
4575 }
4576}
4577
4578#[derive(Debug, Deserialize)]
4579struct GhAuthor {
4580 #[serde(default)]
4581 login: String,
4582}
4583
4584#[derive(Debug, Deserialize)]
4585struct GhReview {
4586 #[serde(default)]
4587 author: GhAuthor,
4588 #[serde(default)]
4589 body: String,
4590}
4591
4592#[derive(Debug, Deserialize)]
4593struct GhComment {
4594 #[serde(default)]
4595 author: GhAuthor,
4596 #[serde(default)]
4597 body: String,
4598}
4599
4600#[derive(Debug, Deserialize)]
4601struct GhUser {
4602 #[serde(default)]
4603 login: String,
4604}
4605
4606#[derive(Debug, Deserialize)]
4607struct GhInline {
4608 #[serde(default)]
4609 user: GhUser,
4610 #[serde(default)]
4611 path: Option<String>,
4612 #[serde(default)]
4613 line: Option<u64>,
4614 #[serde(default)]
4615 body: String,
4616}
4617
4618impl Default for GhAuthor {
4619 fn default() -> Self {
4620 Self {
4621 login: "(unknown)".to_owned(),
4622 }
4623 }
4624}
4625
4626impl Default for GhUser {
4627 fn default() -> Self {
4628 Self {
4629 login: "(unknown)".to_owned(),
4630 }
4631 }
4632}
4633
4634#[cfg(test)]
4635mod tests {
4636 use super::*;
4637 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4638
4639 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4640 format!(
4641 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4642 )
4643 }
4644
4645 #[test]
4646 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4647 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4648 assert_eq!(
4649 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4650 Ok(())
4651 );
4652 for (json, why) in [
4653 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4654 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4655 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4656 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4657 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4658 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4659 ] {
4660 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4661 .unwrap_err()
4662 .why;
4663 assert!(err.contains(why), "{json}: {err}");
4664 }
4665 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4667 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4668 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4669 assert!(
4670 !closable(
4671 &head_json("x", "main", "OPEN", false),
4672 "magi/27b2/A",
4673 "main",
4674 &[]
4675 )
4676 .unwrap_err()
4677 .retry
4678 );
4679 assert!(is_forge_url("https://github.com/o/r.git"));
4680 assert!(is_forge_url("git@github.com:o/r.git"));
4681 assert!(!is_forge_url("/tmp/origin.git"));
4682 assert!(!is_forge_url("C:\\work\\origin.git"));
4683 assert!(!is_forge_url("file:///tmp/origin.git"));
4684 assert!(forge_unavailable(
4685 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4686 ));
4687 assert!(!forge_unavailable(
4688 "gh pr list failed: error connecting to api.github.com"
4689 ));
4690 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4691 assert!(
4693 closable(
4694 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4695 "b",
4696 "main",
4697 &["aaa".to_owned()]
4698 )
4699 .is_err()
4700 );
4701 }
4702
4703 #[test]
4704 fn the_close_comment_names_the_commit_on_the_base() {
4705 let e = crate::already::Evidence {
4706 proof: crate::already::Proof::PatchId,
4707 tip: "1234567890".to_owned(),
4708 commits: vec!["0e368de0000".to_owned()],
4709 };
4710 let c = superseded_comment("main", &e);
4711 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4712 }
4713
4714 const GREEN_OPEN: &str = r####"{
4716 "url": "https://github.com/yukimemi/magi/pull/10",
4717 "number": 10,
4718 "state": "OPEN",
4719 "mergeStateStatus": "CLEAN",
4720 "statusCheckRollup": [
4721 {
4722 "__typename": "CheckRun",
4723 "conclusion": "SKIPPED",
4724 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4725 "name": "review",
4726 "status": "COMPLETED",
4727 "workflowName": "claude-review"
4728 },
4729 {
4730 "__typename": "CheckRun",
4731 "conclusion": "SUCCESS",
4732 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4733 "name": "check (ubuntu-latest)",
4734 "status": "COMPLETED",
4735 "workflowName": "CI"
4736 },
4737 {
4738 "__typename": "CheckRun",
4739 "conclusion": "SUCCESS",
4740 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4741 "name": "rustfmt",
4742 "status": "COMPLETED",
4743 "workflowName": "CI"
4744 },
4745 {
4746 "__typename": "StatusContext",
4747 "context": "CodeRabbit",
4748 "state": "SUCCESS",
4749 "targetUrl": ""
4750 }
4751 ],
4752 "reviews": [],
4753 "comments": [
4754 {
4755 "author": {
4756 "login": "coderabbitai"
4757 },
4758 "authorAssociation": "NONE",
4759 "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"
4760 }
4761 ]
4762}"####;
4763
4764 const RED_OPEN: &str = r####"{
4766 "url": "https://github.com/yukimemi/magi/pull/9",
4767 "number": 9,
4768 "state": "OPEN",
4769 "mergeStateStatus": "UNSTABLE",
4770 "statusCheckRollup": [
4771 {
4772 "__typename": "CheckRun",
4773 "conclusion": "SUCCESS",
4774 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4775 "name": "check (ubuntu-latest)",
4776 "status": "COMPLETED",
4777 "workflowName": "CI"
4778 },
4779 {
4780 "__typename": "CheckRun",
4781 "conclusion": "SUCCESS",
4782 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4783 "name": "rustfmt",
4784 "status": "COMPLETED",
4785 "workflowName": "CI"
4786 },
4787 {
4788 "__typename": "CheckRun",
4789 "conclusion": "FAILURE",
4790 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4791 "name": "editorconfig",
4792 "status": "COMPLETED",
4793 "workflowName": "CI"
4794 },
4795 {
4796 "__typename": "StatusContext",
4797 "context": "CodeRabbit",
4798 "state": "SUCCESS",
4799 "targetUrl": ""
4800 }
4801 ],
4802 "reviews": [],
4803 "comments": [
4804 {
4805 "author": {
4806 "login": "coderabbitai"
4807 },
4808 "authorAssociation": "NONE",
4809 "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"
4810 }
4811 ]
4812}"####;
4813
4814 const PENDING_OPEN: &str = r####"{
4816 "url": "https://github.com/yukimemi/magi/pull/9",
4817 "number": 9,
4818 "state": "OPEN",
4819 "statusCheckRollup": [
4820 {
4821 "__typename": "CheckRun",
4822 "conclusion": "SUCCESS",
4823 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4824 "name": "check (ubuntu-latest)",
4825 "status": "COMPLETED",
4826 "workflowName": "CI"
4827 },
4828 {
4829 "__typename": "CheckRun",
4830 "conclusion": "SUCCESS",
4831 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4832 "name": "rustfmt",
4833 "status": "COMPLETED",
4834 "workflowName": "CI"
4835 },
4836 {
4837 "__typename": "CheckRun",
4838 "conclusion": null,
4839 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4840 "name": "editorconfig",
4841 "status": "IN_PROGRESS",
4842 "workflowName": "CI"
4843 },
4844 {
4845 "__typename": "StatusContext",
4846 "context": "CodeRabbit",
4847 "state": "SUCCESS",
4848 "targetUrl": ""
4849 }
4850 ],
4851 "reviews": [],
4852 "comments": []
4853}"####;
4854
4855 const MERGED: &str = r####"{
4857 "url": "https://github.com/yukimemi/magi/pull/16",
4858 "number": 16,
4859 "state": "MERGED",
4860 "statusCheckRollup": [
4861 {
4862 "__typename": "CheckRun",
4863 "conclusion": "SUCCESS",
4864 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4865 "name": "check (ubuntu-latest)",
4866 "status": "COMPLETED",
4867 "workflowName": "CI"
4868 },
4869 {
4870 "__typename": "CheckRun",
4871 "conclusion": "SUCCESS",
4872 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4873 "name": "review",
4874 "status": "COMPLETED",
4875 "workflowName": "claude-review"
4876 }
4877 ],
4878 "reviews": [],
4879 "comments": []
4880}"####;
4881
4882 const REVIEWED_OPEN: &str = r####"{
4884 "url": "https://github.com/yukimemi/magi/pull/12",
4885 "number": 12,
4886 "state": "OPEN",
4887 "statusCheckRollup": [
4888 {
4889 "__typename": "CheckRun",
4890 "conclusion": "SUCCESS",
4891 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4892 "name": "check (ubuntu-latest)",
4893 "status": "COMPLETED",
4894 "workflowName": "CI"
4895 },
4896 {
4897 "__typename": "CheckRun",
4898 "conclusion": "SUCCESS",
4899 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4900 "name": "review",
4901 "status": "COMPLETED",
4902 "workflowName": "claude-review"
4903 }
4904 ],
4905 "reviews": [
4906 {
4907 "author": {
4908 "login": "claude"
4909 },
4910 "state": "COMMENTED",
4911 "body": ""
4912 }
4913 ],
4914 "comments": [
4915 {
4916 "author": {
4917 "login": "coderabbitai"
4918 },
4919 "authorAssociation": "NONE",
4920 "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"
4921 },
4922 {
4923 "author": {
4924 "login": "claude"
4925 },
4926 "authorAssociation": "NONE",
4927 "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"
4928 }
4929 ]
4930}"####;
4931
4932 const INLINE: &str = r####"[
4934 {
4935 "user": {
4936 "login": "claude[bot]"
4937 },
4938 "path": "src/graph.rs",
4939 "line": 231,
4940 "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"
4941 }
4942]"####;
4943
4944 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4946<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4947
4948> [!IMPORTANT]
4949> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4950>
4951> This repository does not receive automatic reviews because it has fewer than 10 stars.
4952>
4953> <details>
4954> <summary>⚙️ Run configuration</summary>
4955>
4956> **Configuration used**: defaults
4957>
4958> **Review profile**: CHILL
4959>
4960> **Plan**: Team
4961>
4962> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4963>
4964> </details>
4965
4966<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4967
4968<!-- tips_start -->
4969
4970---
4971
4972Thanks 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.
4973
4974<details>
4975<summary>❤️ Share</summary>
4976
4977- [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"####;
4978
4979 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4981
4982---
4983### Reviewing PR #16
4984
4985- [x] Read AGENTS.md conventions
4986- [x] Review `src/daemon.rs` changes
4987- [x] Review `src/main.rs` changes (new `doctor` reporting)
4988- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4989- [x] Check test coverage for new behavior
4990- [x] Run verification commands (blocked — see note)
4991- [x] Post findings"####;
4992
4993 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4995
4996---
4997### Review: `magi review <branch>` — cheap-half-only graph
4998
4999Read 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.
5000
5001**Correctness**
5002
5003- 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"####;
5004
5005 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5006 PrState {
5007 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5008 number: 16,
5009 state: PrLifecycle::Open,
5010 checks,
5011 blocking: if matches!(checks, Checks::Red) {
5015 Blocking::Yes
5016 } else {
5017 Blocking::No
5018 },
5019 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5020 review_comments: (0..comments)
5021 .map(|i| ReviewComment {
5022 author: "coderabbitai".to_owned(),
5023 path: Some("src/graph.rs".to_owned()),
5024 line: Some(231),
5025 body: format!("finding {i}"),
5026 })
5027 .collect(),
5028 }
5029 }
5030
5031 #[test]
5032 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5033 use CiExpectation::{Absent, Expected};
5034 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5035 let p = pr(checks, &["x"], 0);
5036 for waited in [Duration::ZERO, CHECKS_GRACE] {
5037 assert_eq!(
5038 decide_with(&p, 0, 4, waited, Expected),
5039 decide(&p, 0, 4, waited)
5040 );
5041 }
5042 }
5043 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5045 let p = pr(checks, &["x"], 0);
5046 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5047 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5048 }
5049 let mut p = pr(Checks::Unknown, &[], 0);
5051 p.blocking = Blocking::Conflict;
5052 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5053 p.state = PrLifecycle::Merged;
5055 assert_eq!(
5056 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5057 Step::Done { merged: true }
5058 );
5059 }
5060
5061 #[test]
5062 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5063 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5064 assert_eq!(state.number, 10);
5065 assert_eq!(state.state, PrLifecycle::Open);
5066 assert_eq!(state.checks, Checks::Green);
5067 assert!(state.failing.is_empty());
5068 assert!(
5069 state.review_comments.is_empty(),
5070 "the only comment is CodeRabbit's trigger notice: {:?}",
5071 state.review_comments
5072 );
5073 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5074 }
5075
5076 #[test]
5077 fn a_failing_check_parses_as_red_and_is_named() {
5078 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5079 assert_eq!(state.checks, Checks::Red);
5080 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5081 let mut blocking = state.clone();
5088 blocking.blocking = Blocking::Yes;
5089 match decide(&blocking, 0, 4, Duration::ZERO) {
5090 Step::Fix { reason } => {
5091 assert!(reason.contains("editorconfig"), "reason: {reason}");
5092 assert!(reason.contains("failing"), "reason: {reason}");
5093 }
5094 other => panic!("expected a fix round, got {other:?}"),
5095 }
5096 }
5097
5098 #[test]
5099 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5100 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5101 assert_eq!(state.checks, Checks::Pending);
5102 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5103 }
5104
5105 #[test]
5106 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5107 let state = parse_pr(MERGED).expect("merged fixture parses");
5108 assert_eq!(state.state, PrLifecycle::Merged);
5109 assert_eq!(
5110 decide(&state, 0, 4, Duration::ZERO),
5111 Step::Done { merged: true }
5112 );
5113 }
5114
5115 #[test]
5116 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5117 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5118 assert_eq!(state.checks, Checks::Green);
5119 let authors: Vec<&str> = state
5120 .review_comments
5121 .iter()
5122 .map(|c| c.author.as_str())
5123 .collect();
5124 assert_eq!(
5125 authors,
5126 vec!["claude"],
5127 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5128 );
5129 match decide(&state, 0, 4, Duration::ZERO) {
5130 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5131 other => panic!("expected a fix round, got {other:?}"),
5132 }
5133 }
5134
5135 #[test]
5136 fn inline_review_comments_keep_their_file_and_line() {
5137 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5138 assert_eq!(comments.len(), 1);
5139 assert_eq!(comments[0].author, "claude[bot]");
5140 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5141 assert_eq!(comments[0].line, Some(231));
5142 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5143 }
5144
5145 #[test]
5146 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5147 assert!(
5148 is_noise(CODERABBIT_TRIGGER),
5149 "CodeRabbit's trigger notice declares itself not a review"
5150 );
5151 assert!(
5152 is_noise(CLAUDE_CHECKLIST),
5153 "a progress checklist asks for nothing"
5154 );
5155 assert!(
5156 !is_noise(CLAUDE_FINDING),
5157 "a review that names a bug is input, not noise"
5158 );
5159
5160 let mut clean = pr(Checks::Green, &[], 0);
5161 clean.review_comments.push(ReviewComment {
5162 author: "coderabbitai".to_owned(),
5163 path: None,
5164 line: None,
5165 body: CODERABBIT_TRIGGER.to_owned(),
5166 });
5167 clean.review_comments.retain(|c| !is_noise(&c.body));
5168 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5169
5170 let mut found = pr(Checks::Green, &[], 0);
5171 found.review_comments.push(ReviewComment {
5172 author: "claude".to_owned(),
5173 path: None,
5174 line: None,
5175 body: CLAUDE_FINDING.to_owned(),
5176 });
5177 found.review_comments.retain(|c| !is_noise(&c.body));
5178 assert!(matches!(
5179 decide(&found, 0, 4, Duration::ZERO),
5180 Step::Fix { .. }
5181 ));
5182 }
5183
5184 #[test]
5185 fn the_policy_table_holds_for_every_combination_that_matters() {
5186 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5187 (
5188 "pending checks are waited for, even on the last round",
5189 pr(Checks::Pending, &[], 0),
5190 4,
5191 4,
5192 Duration::ZERO,
5193 Step::Wait,
5194 ),
5195 (
5196 "red checks are fixed",
5197 pr(Checks::Red, &["editorconfig"], 0),
5198 0,
5199 4,
5200 Duration::ZERO,
5201 Step::Fix {
5202 reason: "1 check(s) failing: editorconfig".to_owned(),
5203 },
5204 ),
5205 (
5206 "green with comments is fixed, not merged",
5207 pr(Checks::Green, &[], 2),
5208 1,
5209 4,
5210 Duration::ZERO,
5211 Step::Fix {
5212 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5213 .to_owned(),
5214 },
5215 ),
5216 (
5217 "green and clean merges",
5218 pr(Checks::Green, &[], 0),
5219 3,
5220 4,
5221 Duration::ZERO,
5222 Step::Merge,
5223 ),
5224 (
5225 "an unreadable rollup is waited on while the grace lasts",
5226 pr(Checks::Unknown, &[], 0),
5227 0,
5228 4,
5229 Duration::ZERO,
5230 Step::Wait,
5231 ),
5232 (
5233 "an unreadable rollup is never merged once the grace is spent",
5234 pr(Checks::Unknown, &[], 0),
5235 0,
5236 4,
5237 CHECKS_GRACE,
5238 Step::GiveUp {
5239 reason: "no check status is readable on the pull request after 3 minute(s); \
5240 refusing to merge on a guess"
5241 .to_owned(),
5242 },
5243 ),
5244 ];
5245 for (what, state, round, budget, waited, want) in cases {
5246 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5247 }
5248 }
5249
5250 #[test]
5251 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5252 let green = parse_pr(GREEN_OPEN).expect("parse");
5256 assert_eq!(green.blocking, Blocking::No);
5257 let red = parse_pr(RED_OPEN).expect("parse");
5258 assert_eq!(
5259 red.blocking,
5260 Blocking::No,
5261 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5262 );
5263 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5264 let quiet =
5266 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5267 assert_eq!(quiet.blocking, Blocking::Unsaid);
5268 }
5269
5270 #[test]
5271 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5272 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5278 nonblocking.blocking = Blocking::No;
5279 assert_eq!(
5280 decide(&nonblocking, 0, 4, Duration::ZERO),
5281 Step::Merge,
5282 "the forge says nothing is in the way, so nothing is"
5283 );
5284
5285 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5287 blocking.blocking = Blocking::Yes;
5288 assert!(matches!(
5289 decide(&blocking, 0, 4, Duration::ZERO),
5290 Step::Fix { .. }
5291 ));
5292
5293 let mut commented = pr(Checks::Red, &["coverage"], 1);
5296 commented.blocking = Blocking::No;
5297 assert!(matches!(
5298 decide(&commented, 0, 4, Duration::ZERO),
5299 Step::Fix { .. }
5300 ));
5301
5302 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5304 unsaid.blocking = Blocking::Unsaid;
5305 assert!(matches!(
5306 decide(&unsaid, 0, 4, Duration::ZERO),
5307 Step::Fix { .. }
5308 ));
5309 }
5310
5311 #[test]
5312 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5313 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5314 red.blocking = Blocking::No;
5315 assert_eq!(
5316 decide(&red, 0, 4, Duration::ZERO),
5317 Step::Merge,
5318 "announcing must not change the decision"
5319 );
5320 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5321 assert!(said.contains("yukimemi/magi"), "{said}");
5322 assert!(said.contains("#16"), "{said}");
5323 assert!(
5324 said.contains("https://github.com/yukimemi/magi/pull/16"),
5325 "{said}"
5326 );
5327 assert!(
5328 said.contains("test (windows-latest)") && said.contains("coverage"),
5329 "{said}"
5330 );
5331
5332 let green = pr(Checks::Green, &["stale"], 0);
5334 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5335 }
5336
5337 #[test]
5338 fn the_repo_label_comes_from_the_pull_request_url() {
5339 let p = Path::new("/tmp/checkout");
5340 assert_eq!(
5341 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5342 "yukimemi/magi"
5343 );
5344 assert_eq!(repo_label(p, "not a url"), "checkout");
5345 }
5346
5347 #[test]
5348 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5349 let mut conflicted = pr(Checks::Green, &[], 0);
5354 conflicted.blocking = Blocking::Conflict;
5355 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5356
5357 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5361 red.blocking = Blocking::Conflict;
5362 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5363
5364 let mut merged = pr(Checks::Red, &[], 0);
5366 merged.blocking = Blocking::Conflict;
5367 merged.state = PrLifecycle::Merged;
5368 assert_eq!(
5369 decide(&merged, 0, 4, Duration::ZERO),
5370 Step::Done { merged: true }
5371 );
5372 }
5373
5374 #[test]
5375 fn the_forge_verdict_is_read_off_merge_state_status() {
5376 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5379 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5380 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5381 }
5382 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5383 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5384 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5385 for quiet in ["", "UNKNOWN"] {
5388 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5389 assert!(Blocking::of(quiet).stops_a_merge());
5390 }
5391 }
5392
5393 #[test]
5394 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5395 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5396 let jj = "could not determine current branch: failed to run git: not on any branch";
5398
5399 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5400 .expect("the forge says merged, so it merged");
5401 assert!(landed.ok);
5402 assert!(
5403 landed.detail.contains("but the pull request is merged"),
5404 "the record must not read as a clean success: {}",
5405 landed.detail
5406 );
5407 assert!(
5408 landed.detail.contains("not on any branch"),
5409 "and it must keep what the command actually said: {}",
5410 landed.detail
5411 );
5412
5413 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5415 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5416 assert!(merged_after_all(&argv, jj, None).is_none());
5418 }
5419
5420 #[test]
5421 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5422 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5423 state.state = PrLifecycle::Closed;
5424 assert_eq!(
5425 decide(&state, 0, 4, Duration::ZERO),
5426 Step::Done { merged: false },
5427 "a human closing the pull request ends the loop, whatever CI says"
5428 );
5429 }
5430
5431 #[test]
5432 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5433 let red = decide(
5434 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5435 4,
5436 4,
5437 Duration::ZERO,
5438 );
5439 match red {
5440 Step::GiveUp { reason } => {
5441 assert!(reason.contains("editorconfig"), "reason: {reason}");
5442 assert!(reason.contains("test (macos)"), "reason: {reason}");
5443 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5444 }
5445 other => panic!("expected a give-up, got {other:?}"),
5446 }
5447
5448 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5449 match commented {
5450 Step::GiveUp { reason } => {
5451 assert!(reason.contains("unresolved"), "reason: {reason}");
5452 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5453 }
5454 other => panic!("expected a give-up, got {other:?}"),
5455 }
5456 }
5457
5458 #[test]
5459 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5460 let candidate_commit = "magi: candidate A (uncommitted work)";
5461 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5462 let argv = merge_argv(16, &subject);
5463
5464 assert!(argv.contains(&"--squash".to_owned()));
5465 assert!(argv.contains(&"--delete-branch".to_owned()));
5466 assert!(argv.contains(&"--subject".to_owned()));
5467 assert_eq!(
5468 argv.last().map(String::as_str),
5469 Some("add retries to the uploader"),
5470 "the subject must not be the candidate commit message"
5471 );
5472 assert_ne!(subject, candidate_commit);
5473 }
5474
5475 #[test]
5476 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5477 assert_eq!(
5478 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5479 "feat: a queue, an unattended loop, and a phone UI"
5480 );
5481 assert_eq!(
5482 merge_subject("", "# port the retry logic\n\ndetails"),
5483 "port the retry logic",
5484 "an empty title falls back to the task's first line, heading marks stripped"
5485 );
5486 }
5487
5488 #[test]
5489 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5490 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5491 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5492 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5493 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5494 assert_eq!(run_of(""), None);
5495 }
5496
5497 #[test]
5498 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5499 let mut out = Vec::new();
5500 push_if_outstanding(
5501 &mut out,
5502 ReviewComment {
5503 author: "yukimemi".to_owned(),
5504 path: None,
5505 line: None,
5506 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5507 },
5508 );
5509 assert!(out.is_empty());
5510 }
5511
5512 fn run_state() -> RunState {
5516 let mut state = RunState::new(
5517 std::path::PathBuf::from("/repo/magi"),
5518 "main".to_owned(),
5519 "abcdef1234".to_owned(),
5520 "add retries to the uploader".to_owned(),
5521 crate::config::Config::default(),
5522 );
5523 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5528 let nanos = std::time::SystemTime::now()
5529 .duration_since(std::time::UNIX_EPOCH)
5530 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5531 state.id = format!(
5532 "20261004-{nanos:06}-{:04x}",
5533 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5534 );
5535 state
5536 }
5537
5538 fn green_pr() -> PrState {
5539 PrState {
5540 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5541 number: 42,
5542 state: PrLifecycle::Open,
5543 checks: Checks::Green,
5544 blocking: Blocking::No,
5546 failing: Vec::new(),
5547 review_comments: vec![ReviewComment {
5548 author: "coderabbitai".to_owned(),
5549 path: Some("src/land.rs".to_owned()),
5550 line: Some(212),
5551 body: "this branch never checks the exit code".to_owned(),
5552 }],
5553 }
5554 }
5555
5556 #[test]
5557 fn github_facing_land_text_is_english_whatever_the_language() {
5558 let mut state = run_state();
5559 state.config.graph.language = "ja".to_owned();
5560 let comment = stop_comment(&state.id, "checks are still red");
5561 assert!(crate::github_text::check("", &comment).is_empty());
5562 assert!(comment.is_ascii(), "{comment}");
5563 assert!(comment.starts_with(MARKER));
5564
5565 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5566 let ja_at = p.find("Write all prose in ja").unwrap();
5567 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5568 assert!(ja_at < rule_at, "{p}");
5569 assert!(p.contains("stays in Japanese"), "{p}");
5570
5571 state.config.graph.language = "en".to_owned();
5572 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5573 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5574 assert!(!p.contains("does not apply"), "{p}");
5575 }
5576
5577 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5578
5579 fn panel() -> String {
5580 approval_panel(
5581 &run_state(),
5582 &green_pr(),
5583 NUMSTAT,
5584 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5585 &[
5586 "land: ask before merging".to_owned(),
5587 "land: colour the diff".to_owned(),
5588 ],
5589 "feat: merge approval from the phone",
5590 )
5591 }
5592
5593 #[test]
5594 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5595 let html = panel();
5596 for needle in [
5597 "42",
5598 "main",
5599 "src/land.rs",
5600 "src/web.rs",
5601 "assets/logo.png",
5602 "feat: merge approval from the phone",
5603 "land: ask before merging",
5604 "land: colour the diff",
5605 "coderabbitai",
5606 "this branch never checks the exit code",
5607 "green",
5608 ] {
5609 assert!(html.contains(needle), "the panel must state `{needle}`");
5610 }
5611 }
5612
5613 fn winning_candidate(summary: &str) -> Candidate {
5616 Candidate {
5617 index: 0,
5618 label: 'A',
5619 agent: "opus".to_owned(),
5620 branch: "magi/x/A".to_owned(),
5621 worktree: PathBuf::from("/wt/A"),
5622 summary: summary.to_owned(),
5623 stat: String::new(),
5624 files: 1,
5625 commits: 1,
5626 empty: false,
5627 failed: None,
5628 verified_noop: None,
5629 duration_ms: 0,
5630 folded: false,
5631 }
5632 }
5633
5634 fn uncontested_tally() -> Tally {
5635 Tally {
5636 first_choice: BTreeMap::from([('A', 1)]),
5637 borda: BTreeMap::new(),
5638 winner: 'A',
5639 rankings: 1,
5640 unanimous_initial: true,
5641 deliberated: false,
5642 changed_votes: 0,
5643 unanimous_final: true,
5644 tie_break: None,
5645 judges: 1,
5646 present: 1,
5647 quorum: 1,
5648 met_quorum: true,
5649 uncontested: None,
5650 }
5651 }
5652
5653 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5654 ReviewRecord {
5655 attempts: 0,
5656 reviewer,
5657 agent: agent.to_owned(),
5658 summary: summary.to_owned(),
5659 findings: Vec::new(),
5660 vote: None,
5661 failed: None,
5662 duration_ms: 0,
5663 }
5664 }
5665
5666 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5667 let answered = reviews.len();
5668 ReviewRound {
5669 round,
5670 head: "abc1234".to_owned(),
5671 verified_head: None,
5672 verified_at: None,
5673 reviews,
5674 e2e: Vec::new(),
5675 verify_retried: false,
5676 e2e_deferred: false,
5677 e2e_defer_reason: None,
5678 fix: None,
5679 blocking: 0,
5680 answered,
5681 expected: answered,
5682 clean: true,
5683 progressed: false,
5684 vote_split: false,
5685 reconsideration: Vec::new(),
5686 verdict: None,
5687 }
5688 }
5689
5690 #[test]
5691 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5692 let en = panel();
5693 assert!(en.contains("Task"), "{en}");
5694 assert!(en.contains("add retries to the uploader"), "{en}");
5695
5696 let mut state = run_state();
5697 state.config.graph.language = "ja".to_owned();
5698 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5699 assert!(ja.contains("タスク"), "{ja}");
5700 assert!(
5701 ja.contains("add retries to the uploader"),
5702 "the task itself is not translated: {ja}"
5703 );
5704 }
5705
5706 #[test]
5707 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5708 let html = panel();
5712 assert!(!html.contains("What changed"), "{html}");
5713 assert!(!html.contains("Review verdict"), "{html}");
5714 }
5715
5716 #[test]
5717 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5718 let mut state = run_state();
5719 state.candidates = vec![winning_candidate("")];
5720 state.tally = Some(uncontested_tally());
5721 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5722 assert!(
5723 !html.contains("What changed"),
5724 "an empty summary must not render an empty box: {html}"
5725 );
5726 }
5727
5728 #[test]
5729 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5730 let mut state = run_state();
5731 state.candidates = vec![winning_candidate(
5732 "Added a retry loop around the uploader PUT call.",
5733 )];
5734 state.tally = Some(uncontested_tally());
5735 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5736 assert!(en.contains("What changed"), "{en}");
5737 assert!(
5738 en.contains("Added a retry loop around the uploader PUT call."),
5739 "{en}"
5740 );
5741
5742 state.config.graph.language = "ja".to_owned();
5743 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5744 assert!(ja.contains("変更内容"), "{ja}");
5745 assert!(
5746 ja.contains("Added a retry loop around the uploader PUT call."),
5747 "{ja}"
5748 );
5749 }
5750
5751 #[test]
5752 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5753 let mut state = run_state();
5754 state.reviews = vec![
5755 review_round(
5756 1,
5757 vec![review_record(1, "alpha", "found a race, sent back")],
5758 ),
5759 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5760 ];
5761 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5762 assert!(en.contains("Review verdict"), "{en}");
5763 assert!(en.contains("race is fixed, clean"), "{en}");
5764 assert!(
5765 !en.contains("found a race, sent back"),
5766 "only the round that actually cleared the merge should show: {en}"
5767 );
5768
5769 state.config.graph.language = "ja".to_owned();
5770 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5771 assert!(ja.contains("レビューの結論"), "{ja}");
5772 assert!(ja.contains("レビュアー"), "{ja}");
5773 assert!(ja.contains("race is fixed, clean"), "{ja}");
5774 }
5775
5776 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5782 ReviewRecord {
5783 attempts: 0,
5784 reviewer,
5785 agent: agent.to_owned(),
5786 summary: String::new(),
5787 findings: Vec::new(),
5788 vote: None,
5789 failed: Some(reason.to_owned()),
5790 duration_ms: 0,
5791 }
5792 }
5793
5794 #[test]
5795 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5796 let mut state = run_state();
5797 state.reviews = vec![review_round(
5798 1,
5799 vec![
5800 review_record(1, "alpha", "clean, nothing to add"),
5801 unanswered_review_record(2, "beta", "timed out"),
5802 ],
5803 )];
5804 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5805 assert!(en.contains("clean, nothing to add"), "{en}");
5806 assert!(
5807 en.contains("produced no answer: timed out"),
5808 "a seat that never answered must say so, not render a blank box: {en}"
5809 );
5810 assert!(
5811 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5812 "no reviewer box may be left empty: {en}"
5813 );
5814
5815 state.config.graph.language = "ja".to_owned();
5816 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5817 assert!(ja.contains("回答なし: timed out"), "{ja}");
5818 }
5819
5820 #[test]
5821 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5822 let html = panel();
5823 assert!(!html.contains("<script"), "no script survives the csp");
5824 assert!(!html.contains("<form"), "form-action is 'none'");
5825 let pr = green_pr();
5826 assert_eq!(
5827 html.matches("http").count(),
5828 html.matches(pr.url.as_str()).count(),
5829 "the only http url in the panel is the pull request's own link"
5830 );
5831 assert!(
5832 html.contains("target=\"_blank\""),
5833 "the PR link must open in a new tab so it escapes the iframe"
5834 );
5835 assert!(
5836 html.contains("rel=\"noopener noreferrer\""),
5837 "external links opening in new tabs must carry noopener noreferrer"
5838 );
5839 }
5840
5841 #[test]
5842 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5843 let html = panel();
5844 assert!(
5845 html.contains(">+</span>"),
5846 "an added line carries a `+` in the gutter, not only a background"
5847 );
5848 assert!(
5849 html.contains(">-</span>"),
5850 "a removed line carries a `-` in the gutter, not only a background"
5851 );
5852 assert!(
5853 html.contains(">new line</span>"),
5854 "the marker is moved to the gutter, so the body is printed once without it"
5855 );
5856 }
5857
5858 #[test]
5859 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5860 let total = DIFF_MAX_LINES + 100;
5861 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5862 let html = approval_panel(
5863 &run_state(),
5864 &green_pr(),
5865 NUMSTAT,
5866 &diff,
5867 &[],
5868 "feat: something long",
5869 );
5870 assert!(
5871 html.contains(&format!("100 of {total} diff lines omitted")),
5872 "the note must say exactly how much was cut"
5873 );
5874 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5875 assert!(
5876 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5877 "nothing past the threshold is rendered"
5878 );
5879 assert!(
5880 html.contains("/repo/magi"),
5881 "the note says where the rest is"
5882 );
5883 }
5884
5885 #[test]
5886 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5887 let html = approval_panel(
5888 &run_state(),
5889 &green_pr(),
5890 "1\t2\tsrc/<b>&\"x\"'.rs",
5891 "",
5892 &[],
5893 "subject",
5894 );
5895 assert!(html.contains("src/<b>&"x"'.rs"));
5896 assert!(
5897 !html.contains("<b>"),
5898 "an agent-influenced path must never become markup"
5899 );
5900 }
5901
5902 #[tokio::test]
5903 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5904 let a = std::path::PathBuf::from("/repo/a");
5905 let b = std::path::PathBuf::from("/repo/b");
5906
5907 let held = repo_merge_lock(&a).lock_owned().await;
5908
5909 assert!(
5912 repo_merge_lock(&a).try_lock().is_err(),
5913 "a second merge into the same repository must not proceed concurrently"
5914 );
5915
5916 assert!(
5920 repo_merge_lock(&b).try_lock().is_ok(),
5921 "a different repository's merge lock must be independent"
5922 );
5923
5924 drop(held);
5925 assert!(
5926 repo_merge_lock(&a).try_lock().is_ok(),
5927 "the lock is released once the holder is done"
5928 );
5929 }
5930
5931 #[test]
5932 fn only_the_merge_choice_merges_and_silence_holds() {
5933 let table = [
5934 (None, Approval::Hold),
5935 (Some("merge"), Approval::Merge),
5936 (Some(" merge\n"), Approval::Merge),
5937 (Some("hold"), Approval::Hold),
5938 (Some(""), Approval::Hold),
5939 (Some("yes"), Approval::Hold),
5940 ];
5941 for (answer, want) in table {
5942 assert_eq!(
5943 approval(answer),
5944 want,
5945 "answer {answer:?} must resolve to {want:?}"
5946 );
5947 }
5948 }
5949
5950 #[tokio::test]
5951 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5952 let mut state = landing_state();
5953 state.config.graph.land_approval = true;
5954 let pr = green_pr();
5955
5956 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5957 .await
5958 .unwrap();
5959 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5960 assert!(
5961 !state.parked,
5962 "approval_gate itself never sets `parked`; only its caller does"
5963 );
5964
5965 let store = ask::Questions::open();
5966 let filed: Vec<_> = store
5967 .list()
5968 .into_iter()
5969 .filter(|q| q.run == state.id)
5970 .collect();
5971 assert_eq!(filed.len(), 1, "exactly one question is filed");
5972 assert_eq!(filed[0].node, APPROVAL_NODE);
5973 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5974 assert!(filed[0].status.open());
5975
5976 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5980 .await
5981 .unwrap();
5982 assert_eq!(again, ApprovalGate::Pending);
5983 let still_one = store
5984 .list()
5985 .into_iter()
5986 .filter(|q| q.run == state.id)
5987 .count();
5988 assert_eq!(
5989 still_one, 1,
5990 "asking twice must not double-file the question"
5991 );
5992 }
5993
5994 #[tokio::test]
5995 async fn approving_the_existing_question_is_read_back_as_approved() {
5996 crate::run::pin_test_home();
5997 let mut state = run_state();
5998 state.config.graph.land_approval = true;
5999 let pr = green_pr();
6000 assert_eq!(
6001 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6002 .await
6003 .unwrap(),
6004 ApprovalGate::Pending
6005 );
6006
6007 let store = ask::Questions::open();
6008 let mut q = store
6009 .list()
6010 .into_iter()
6011 .find(|q| q.run == state.id)
6012 .expect("filed above");
6013 let talks = crate::talk::Talks::open();
6014 let cfg = crate::config::Config {
6015 agents: vec![crate::config::AgentSpec {
6016 id: "mock".into(),
6017 kind: crate::config::AgentKind::Command,
6018 model: None,
6019 command: vec!["true".into()],
6020 extra_args: Vec::new(),
6021 env: Default::default(),
6022 prompt_delivery: None,
6023 }],
6024 ..crate::config::Config::default()
6025 };
6026 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6027 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6028 crate::talk::drain(&mut talk, &talks).unwrap();
6029 assert_eq!(
6030 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6031 .await
6032 .unwrap(),
6033 ApprovalGate::Pending,
6034 "consultation cannot approve or hold the merge"
6035 );
6036 q = store.get(&q.id).unwrap();
6037 assert!(q.answer.is_none());
6038 talk.turns.push(crate::talk::Turn {
6039 breaks: Some(Vec::new()),
6040 posted_by: None,
6041 who: crate::talk::Who::Operator,
6042 body: "Merge this pull request now".into(),
6043 at: Timestamp::now(),
6044 attachments: Vec::new(),
6045 usage: None,
6046 });
6047 talks.put(&mut talk).unwrap();
6048 crate::consult::validate_answer(
6049 &q,
6050 &talks,
6051 &talk.id,
6052 crate::queue::CHAT_NODE,
6053 APPROVE,
6054 Some("Merge this pull request now"),
6055 )
6056 .unwrap();
6057 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6058 store.put(&mut q).unwrap();
6059
6060 assert_eq!(
6061 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6062 .await
6063 .unwrap(),
6064 ApprovalGate::Approved
6065 );
6066 }
6067
6068 #[tokio::test]
6069 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6070 crate::run::pin_test_home();
6071 let store = ask::Questions::open();
6072
6073 let mut held_state = run_state();
6074 held_state.config.graph.land_approval = true;
6075 let pr = green_pr();
6076 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6077 .await
6078 .unwrap();
6079 let mut q = store
6080 .list()
6081 .into_iter()
6082 .find(|q| q.run == held_state.id)
6083 .expect("filed above");
6084 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6085 store.put(&mut q).unwrap();
6086 assert_eq!(
6087 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6088 .await
6089 .unwrap(),
6090 ApprovalGate::Held
6091 );
6092
6093 let mut abandoned_state = run_state();
6094 abandoned_state.config.graph.land_approval = true;
6095 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6096 .await
6097 .unwrap();
6098 let mut q = store
6099 .list()
6100 .into_iter()
6101 .find(|q| q.run == abandoned_state.id)
6102 .expect("filed above");
6103 q.abandon("no answer within the timeout");
6104 store.put(&mut q).unwrap();
6105 assert_eq!(
6106 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6107 .await
6108 .unwrap(),
6109 ApprovalGate::Held,
6110 "silence must never merge"
6111 );
6112 }
6113
6114 fn contested() -> ContestedHandoff {
6115 let finding = |id: &str, n: u32| crate::verdict::Finding {
6116 id: id.to_owned(),
6117 severity: crate::verdict::Severity::Major,
6118 file: Some("src/a.rs".to_owned()),
6119 line: Some(n),
6120 title: format!("problem {id}"),
6121 detail: String::new(),
6122 };
6123 ContestedHandoff {
6124 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6125 rejecters: vec![(1, "alpha".to_owned())],
6126 }
6127 }
6128
6129 #[test]
6130 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6131 let mut state = run_state();
6132 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6133 state.contested_handoff = Some(contested());
6134 assert!(contested_to_ask(&state).is_some());
6135 state.config.graph.hold_contested_merge = false;
6136 assert!(
6137 contested_to_ask(&state).is_none(),
6138 "the switch restores today"
6139 );
6140 }
6141
6142 #[test]
6143 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6144 let q = ask::Question::new(
6145 "run-1".to_owned(),
6146 APPROVAL_NODE.to_owned(),
6147 "land".to_owned(),
6148 "Merge?".to_owned(),
6149 String::new(),
6150 vec![APPROVE.to_owned(), HOLD.to_owned()],
6151 );
6152 let none = deputy_brief(&q, None);
6153 assert!(none.contains("could not be read"), "{none}");
6154 assert!(none.contains("Silence is a hold"), "{none}");
6155
6156 let mut state = run_state();
6157 state.pr = Some(crate::run::PrRecord {
6158 url: "https://example.test/pull/7".to_owned(),
6159 number: 7,
6160 state: "open".to_owned(),
6161 checks: "green".to_owned(),
6162 round: 0,
6163 rounds: 3,
6164 red_at_merge: Vec::new(),
6165 });
6166 state.contested_handoff = Some(contested());
6167 let b = deputy_brief(&q, Some(&state));
6168 assert!(b.contains("https://example.test/pull/7"), "{b}");
6169 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6170 assert!(b.contains("#1"), "the rejecting seat: {b}");
6171 state.contested_handoff = None;
6172 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6173 }
6174
6175 #[test]
6176 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6177 for lang in ["en", "ja"] {
6178 let mut cfg = crate::config::Config::default();
6179 cfg.graph.language = lang.to_owned();
6180 let w = words(&cfg.graph.language);
6181 let text = w.approval_detail(
6182 "https://github.com/yukimemi/magi/pull/42",
6183 "main",
6184 "feat: x",
6185 Some(&contested()),
6186 );
6187 assert!(text.contains("pull/42"), "{text}");
6188 assert!(
6189 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6190 "{text}"
6191 );
6192 assert!(text.contains("R3-1-5"), "{text}");
6193 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6194 assert!(text.contains("2"), "the rest are counted: {text}");
6195 assert!(text.contains("#1 (alpha)"), "{text}");
6196 }
6197 let plain = words("en").approval_detail("u", "main", "s", None);
6198 assert!(!plain.contains("reject"), "{plain}");
6199 }
6200
6201 #[tokio::test]
6202 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6203 crate::run::pin_test_home();
6204 let mut state = run_state();
6205 state.config.graph.land_approval = false;
6206 state.contested_handoff = Some(contested());
6207 let pr = green_pr();
6208 let c = contested_to_ask(&state);
6209 assert_eq!(
6210 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6211 .await
6212 .unwrap(),
6213 ApprovalGate::Pending,
6214 "silence is a hold"
6215 );
6216 let store = ask::Questions::open();
6217 let filed: Vec<_> = store
6218 .list()
6219 .into_iter()
6220 .filter(|q| q.run == state.id)
6221 .collect();
6222 assert_eq!(filed.len(), 1);
6223 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6224
6225 assert_eq!(
6226 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6227 .await
6228 .unwrap(),
6229 ApprovalGate::Pending
6230 );
6231 let mut q = store
6232 .list()
6233 .into_iter()
6234 .find(|q| q.run == state.id)
6235 .unwrap();
6236 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6237 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6238 store.put(&mut q).unwrap();
6239 assert_eq!(
6240 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6241 .await
6242 .unwrap(),
6243 ApprovalGate::Approved
6244 );
6245 }
6246
6247 #[test]
6248 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6249 let rows = parse_numstat(NUMSTAT);
6250 assert_eq!(
6251 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6252 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6253 );
6254 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6255 }
6256 #[test]
6257 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6258 let mut state = run_state();
6262 state.config.graph.language = "ja".to_owned();
6263 let pr = green_pr();
6264 let commits = ["c1".to_owned()];
6265
6266 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6267 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6268 assert!(ja.contains("squash されるコミット"), "{ja}");
6269 assert!(ja.contains("レビューコメント"), "{ja}");
6270 assert!(ja.contains("差分"), "{ja}");
6271 assert!(
6272 !ja.contains("Commits being squashed"),
6273 "no English left over"
6274 );
6275
6276 let w = words("ja");
6277 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6278 assert!(
6279 w.approval_detail("http://x/1", "main", "feat: x", None)
6280 .contains("パネル")
6281 );
6282
6283 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6285 assert!(ja.contains("feat: x"), "nor is the merge subject");
6286
6287 state.config.graph.language = "en".to_owned();
6290 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6291 assert!(en.contains("Commits being squashed"), "{en}");
6292 assert_eq!(words("Klingon").html_lang, "en");
6293 }
6294
6295 #[test]
6299 fn pick_open_pr_classifies_by_count_and_base() {
6300 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6301 assert_eq!(
6302 pick_open_pr(one, "main").unwrap(),
6303 OpenPr::One {
6304 url: "https://x/pull/58".into(),
6305 title: "t".into()
6306 }
6307 );
6308 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6309 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6310 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6311 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6312 assert_eq!(
6313 pick_open_pr(two, "main").unwrap(),
6314 OpenPr::Many(vec!["u1".into(), "u2".into()])
6315 );
6316 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6318 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6319 assert!(pick_open_pr("not json", "main").is_err());
6320 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6322 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6323 }
6324
6325 #[test]
6326 fn pick_merged_pr_picks_the_unique_match() {
6327 let json = r#"[
6328 {"url": "https://github.com/o/r/pull/42", "number": 42,
6329 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6330 ]"#;
6331 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6332 let found = pick_merged_pr(json, "main", created_at)
6333 .expect("valid json")
6334 .expect("one unambiguous match");
6335 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6336 assert_eq!(found.number, 42);
6337 }
6338
6339 #[test]
6343 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6344 let json = r#"[
6345 {"url": "https://github.com/o/r/pull/42", "number": 42,
6346 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6347 {"url": "https://github.com/o/r/pull/43", "number": 43,
6348 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6349 ]"#;
6350 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6351 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6352 }
6353
6354 #[test]
6358 fn pick_merged_pr_ignores_a_different_base_branch() {
6359 let json = r#"[
6360 {"url": "https://github.com/o/r/pull/42", "number": 42,
6361 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6362 ]"#;
6363 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6364 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6365 }
6366
6367 #[test]
6370 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6371 let json = r#"[
6372 {"url": "https://github.com/o/r/pull/42", "number": 42,
6373 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6374 ]"#;
6375 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6376 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6377 }
6378
6379 #[test]
6380 fn slug_of_pr_url_reads_host_owner_and_repo() {
6381 assert_eq!(
6382 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6383 Some("github.com/yukimemi/shun")
6384 );
6385 }
6386
6387 #[test]
6388 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6389 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6390 assert_eq!(slug_of_pr_url("not a url at all"), None);
6391 assert_eq!(slug_of_pr_url("https://github.com"), None);
6392 }
6393
6394 #[test]
6395 fn slug_of_repo_url_reads_host_owner_and_repo() {
6396 assert_eq!(
6397 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6398 Some("github.com/yukimemi/magi")
6399 );
6400 assert_eq!(slug_of_repo_url("https://github.com"), None);
6401 }
6402
6403 #[test]
6404 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6405 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6406 .expect("same repo, different case");
6407 }
6408
6409 #[test]
6414 fn ensure_same_repo_refuses_a_different_repo() {
6415 let err =
6416 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6417 let msg = format!("{err:#}");
6418 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6419 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6420 }
6421
6422 #[test]
6427 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6428 let err = ensure_same_repo(
6429 "github.com/yukimemi/magi",
6430 "github.example.com/yukimemi/magi",
6431 )
6432 .unwrap_err();
6433 let msg = format!("{err:#}");
6434 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6435 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6436 }
6437
6438 #[tokio::test]
6443 async fn find_external_merge_returns_none_without_a_winner() {
6444 let state = RunState::new(
6445 PathBuf::from("/no/such/repo"),
6446 "main".to_owned(),
6447 "0000000000000000000000000000000000000000".to_owned(),
6448 "irrelevant".to_owned(),
6449 crate::config::Config::default(),
6450 );
6451 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6452 }
6453
6454 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6455 let mut run = RunState::new(
6456 PathBuf::from(repo),
6457 "main".to_owned(),
6458 "abcdef1234".to_owned(),
6459 "x".to_owned(),
6460 crate::config::Config::default(),
6461 );
6462 run.id = id.to_owned();
6463 run.status = status;
6464 run.pr = Some(crate::run::PrRecord {
6465 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6466 url: url.to_owned(),
6467 state: state.to_owned(),
6468 checks: "red".to_owned(),
6469 round: 0,
6470 rounds: 2,
6471 red_at_merge: Vec::new(),
6472 });
6473 run.save_under(home).unwrap();
6474 }
6475
6476 fn recorded(home: &Path, id: &str) -> String {
6477 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6478 serde_json::from_str::<RunState>(&body)
6479 .unwrap()
6480 .pr
6481 .unwrap()
6482 .state
6483 }
6484
6485 const PR: &str = "https://github.com/o/r/pull/7";
6486
6487 #[test]
6488 fn write_through_updates_predecessors_and_siblings_only() {
6489 let tmp = tempfile::tempdir().unwrap();
6490 let h = tmp.path();
6491 pr_run(
6492 h,
6493 "20261004-100000-aaaa",
6494 "/repo/r",
6495 RunStatus::Superseded,
6496 PR,
6497 "open",
6498 );
6499 pr_run(
6500 h,
6501 "20261004-100100-bbbb",
6502 "/repo/r",
6503 RunStatus::Blocked,
6504 PR,
6505 "open",
6506 );
6507 pr_run(
6509 h,
6510 "20261004-100200-cccc",
6511 "/repo/r",
6512 RunStatus::Landing,
6513 PR,
6514 "open",
6515 );
6516 pr_run(
6518 h,
6519 "20261004-100300-dddd",
6520 "/repo/other",
6521 RunStatus::Blocked,
6522 "https://github.com/o/other/pull/7",
6523 "open",
6524 );
6525 pr_run(
6527 h,
6528 "20261004-100400-eeee",
6529 "/repo/r",
6530 RunStatus::Blocked,
6531 "https://github.com/o/r/pull/8",
6532 "open",
6533 );
6534 pr_run(
6535 h,
6536 "20261004-100500-ffff",
6537 "/repo/r",
6538 RunStatus::Merged,
6539 PR,
6540 "open",
6541 );
6542 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6543
6544 assert_eq!(
6545 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6546 2
6547 );
6548 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6549 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6550 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6551 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6552 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6553 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6555 assert_eq!(
6557 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6558 0
6559 );
6560 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6561 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6562 }
6563
6564 #[test]
6565 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6566 let tmp = tempfile::tempdir().unwrap();
6567 let h = tmp.path();
6568 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6569 pr_run(
6570 h,
6571 "20261004-100000-aaaa",
6572 "/repo/r",
6573 RunStatus::Superseded,
6574 &url(1),
6575 "open",
6576 );
6577 pr_run(
6578 h,
6579 "20261004-100100-bbbb",
6580 "/repo/r",
6581 RunStatus::Blocked,
6582 &url(2),
6583 "open",
6584 );
6585 pr_run(
6586 h,
6587 "20261004-100200-cccc",
6588 "/repo/r",
6589 RunStatus::Ready,
6590 &url(3),
6591 "open",
6592 );
6593 pr_run(
6594 h,
6595 "20261004-100300-dddd",
6596 "/repo/r",
6597 RunStatus::Ready,
6598 &url(4),
6599 "open",
6600 );
6601 pr_run(
6602 h,
6603 "20261004-100400-eeee",
6604 "/repo/r",
6605 RunStatus::Implementing,
6606 &url(1),
6607 "open",
6608 );
6609 assert_eq!(stale_open_prs(h).len(), 4);
6610
6611 let mut known = BTreeMap::new();
6612 known.insert(url(1), PrLifecycle::Merged);
6613 known.insert(url(2), PrLifecycle::Closed);
6614 known.insert(url(3), PrLifecycle::Open);
6615 assert_eq!(apply_pr_states(h, &known), 2);
6617 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6618 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6619 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6620 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6621 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6622 assert_eq!(apply_pr_states(h, &known), 0);
6623 }
6624
6625 use std::collections::VecDeque;
6628 use std::sync::Mutex;
6629
6630 struct Scripted {
6633 views: Mutex<VecDeque<Seen>>,
6634 merges: Mutex<VecDeque<(bool, String)>>,
6635 fix: Mutex<Option<Fixed>>,
6636 log: Mutex<Vec<&'static str>>,
6637 argvs: Mutex<Vec<Vec<String>>>,
6638 required: Mutex<Option<BTreeSet<String>>>,
6639 merged: Mutex<bool>,
6642 queued: Mutex<bool>,
6643 unreadable_after_merge: Mutex<bool>,
6645 reruns: Mutex<Vec<String>>,
6647 rerun_fails: Mutex<bool>,
6648 clock: Mutex<i64>,
6650 }
6651
6652 impl Scripted {
6653 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6654 Self {
6655 views: Mutex::new(views.into()),
6656 merges: Mutex::new(
6657 merges
6658 .into_iter()
6659 .map(|(ok, m)| (ok, m.to_owned()))
6660 .collect(),
6661 ),
6662 fix: Mutex::new(None),
6663 log: Mutex::new(Vec::new()),
6664 argvs: Mutex::new(Vec::new()),
6665 required: Mutex::new(None),
6666 merged: Mutex::new(false),
6667 queued: Mutex::new(false),
6668 unreadable_after_merge: Mutex::new(false),
6669 reruns: Mutex::new(Vec::new()),
6670 rerun_fails: Mutex::new(false),
6671 clock: Mutex::new(1_000),
6672 }
6673 }
6674 fn argvs(&self) -> Vec<Vec<String>> {
6675 self.argvs.lock().unwrap().clone()
6676 }
6677 fn calls(&self) -> Vec<&'static str> {
6678 self.log.lock().unwrap().clone()
6679 }
6680 }
6681
6682 impl Forge for Scripted {
6683 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6684 self.log.lock().unwrap().push("view");
6685 if *self.unreadable_after_merge.lock().unwrap()
6686 && !self.argvs.lock().unwrap().is_empty()
6687 {
6688 anyhow::bail!("forge unreachable");
6689 }
6690 let mut v = self.views.lock().unwrap();
6691 let mut seen = if v.len() > 1 {
6692 v.pop_front().unwrap()
6693 } else {
6694 v[0].clone()
6695 };
6696 if *self.merged.lock().unwrap() {
6697 seen.pr.state = PrLifecycle::Merged;
6698 }
6699 Ok(seen)
6700 }
6701 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6702 self.log.lock().unwrap().push("merge");
6703 self.argvs.lock().unwrap().push(argv.to_vec());
6704 let out = self
6705 .merges
6706 .lock()
6707 .unwrap()
6708 .pop_front()
6709 .expect("unscripted merge");
6710 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6711 {
6712 *self.merged.lock().unwrap() = true;
6713 }
6714 Ok(out)
6715 }
6716 async fn poll(&self) {
6717 self.log.lock().unwrap().push("poll");
6718 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
6719 }
6720 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
6721 self.log.lock().unwrap().push("rerun");
6722 self.reruns.lock().unwrap().push(run.to_owned());
6723 if *self.rerun_fails.lock().unwrap() {
6724 anyhow::bail!("gh run rerun: no");
6725 }
6726 Ok(())
6727 }
6728 fn now(&self) -> i64 {
6729 *self.clock.lock().unwrap()
6730 }
6731 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6732 self.required.lock().unwrap().clone()
6733 }
6734 async fn fix(
6735 &self,
6736 _state: &mut RunState,
6737 _pr: &PrState,
6738 _round: usize,
6739 _budget: usize,
6740 _reason: &str,
6741 _logs: &str,
6742 ) -> Result<Fixed> {
6743 self.log.lock().unwrap().push("fix");
6744 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6745 }
6746 }
6747
6748 const REFUSED: &str =
6749 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6750
6751 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6752 let mut pr = green_pr();
6753 pr.checks = checks;
6754 pr.blocking = Blocking::of(merge_state);
6755 if !comments {
6756 pr.review_comments.clear();
6757 }
6758 Seen {
6759 pr,
6760 title: "feat: x".to_owned(),
6761 failing_urls: Vec::new(),
6762 head: head.to_owned(),
6763 rollup_head: head.to_owned(),
6764 merge_state: merge_state.to_owned(),
6765 contexts: Vec::new(),
6766 base: "main".to_owned(),
6767 }
6768 }
6769
6770 fn landing_state() -> RunState {
6771 crate::run::pin_test_home();
6772 let mut state = run_state();
6773 state.config.graph.land_approval = false;
6774 state
6775 }
6776
6777 #[test]
6778 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6779 assert!(!awaiting_new_head(None, "aaa"));
6780 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6781 assert!(awaiting_new_head(Some("abc123"), "def456"));
6782 assert!(awaiting_new_head(Some("abc123"), ""));
6783 }
6784
6785 #[test]
6786 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6787 let open = |c, m: &str| seen("a", c, m, false);
6788 let table = [
6789 (None, false, Refused::Pending),
6790 (
6791 Some(open(Checks::Pending, "BLOCKED")),
6792 false,
6793 Refused::Pending,
6794 ),
6795 (
6796 Some(open(Checks::Unknown, "BLOCKED")),
6797 false,
6798 Refused::Pending,
6799 ),
6800 (
6801 Some(open(Checks::Green, "UNKNOWN")),
6802 false,
6803 Refused::Pending,
6804 ),
6805 (Some(open(Checks::Green, "")), false, Refused::Pending),
6806 (
6807 Some(open(Checks::Green, "BLOCKED")),
6808 false,
6809 Refused::Recheck,
6810 ),
6811 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6812 ];
6813 for (after, rechecked, want) in table {
6814 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6815 }
6816 let mut closed = open(Checks::Green, "CLEAN");
6817 closed.pr.state = PrLifecycle::Closed;
6818 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6819 }
6820
6821 #[tokio::test]
6822 async fn a_normal_landing_merges_on_the_first_look() {
6823 let mut state = landing_state();
6824 let forge = Scripted::new(
6825 vec![seen("a", Checks::Green, "CLEAN", false)],
6826 vec![(true, "")],
6827 );
6828 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6829 .await
6830 .unwrap();
6831 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6833 assert_eq!(state.status, RunStatus::Merged);
6834 }
6835
6836 #[tokio::test]
6837 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6838 let mut state = landing_state();
6839 let forge = Scripted::new(
6840 vec![seen("a", Checks::Green, "CLEAN", false)],
6841 std::iter::repeat_n((true, ""), 100).collect(),
6842 );
6843 *forge.queued.lock().unwrap() = true;
6844 let task = async {
6845 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6846 .await
6847 .unwrap();
6848 };
6849 task.await;
6852 assert_ne!(state.status, RunStatus::Merged);
6853 }
6854
6855 #[tokio::test]
6856 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6857 let mut state = landing_state();
6858 let forge = Scripted::new(
6859 vec![seen("a", Checks::Green, "CLEAN", false)],
6860 std::iter::repeat_n((true, ""), 100).collect(),
6861 );
6862 *forge.unreadable_after_merge.lock().unwrap() = true;
6863 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6864 .await
6865 .ok();
6866 assert_ne!(state.status, RunStatus::Merged);
6867 }
6868
6869 #[tokio::test]
6870 async fn a_task_public_head_is_awaited_on_land_entry() {
6871 let mut state = landing_state();
6872 state.pr_head = Some("magi/task-abcd-1234".into());
6873 state.pr_pushed_head = Some("new".into());
6874 let forge = Scripted::new(
6875 vec![
6876 seen("old", Checks::Green, "CLEAN", false),
6877 seen("new", Checks::Green, "CLEAN", false),
6878 ],
6879 vec![(true, "")],
6880 );
6881 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6882 .await
6883 .unwrap();
6884 assert_eq!(
6885 forge.calls(),
6886 ["view", "poll", "view", "view", "merge", "view"]
6887 );
6888 assert_eq!(state.status, RunStatus::Merged);
6889 }
6890
6891 #[tokio::test]
6892 async fn a_superseded_public_head_owner_cannot_reenter_land() {
6893 let mut state = landing_state();
6894 state.pr_taken_over_by = Some("next".into());
6895 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
6896 assert!(
6897 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6898 .await
6899 .is_err()
6900 );
6901 assert!(forge.calls().is_empty());
6902 }
6903
6904 #[tokio::test]
6905 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6906 let mut state = landing_state();
6907 let forge = Scripted::new(
6908 vec![
6909 seen("old", Checks::Green, "CLEAN", true),
6910 seen("old", Checks::Green, "CLEAN", true),
6912 seen("new", Checks::Pending, "BLOCKED", true),
6913 seen("new", Checks::Green, "CLEAN", true),
6914 ],
6915 vec![(true, "")],
6916 );
6917 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6918 head: "NEW".to_owned(),
6919 });
6920 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6921 .await
6922 .unwrap();
6923 assert_eq!(
6924 forge.calls(),
6925 [
6926 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6927 "view"
6928 ]
6929 );
6930 assert_eq!(state.status, RunStatus::Merged);
6931 }
6932
6933 #[tokio::test]
6934 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6935 let mut state = landing_state();
6936 let forge = Scripted::new(
6937 vec![
6938 seen("old", Checks::Green, "CLEAN", true),
6939 seen("someone-elses", Checks::Green, "CLEAN", true),
6940 ],
6941 vec![],
6942 );
6943 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6944 head: "mine".to_owned(),
6945 });
6946 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6947 .await
6948 .unwrap();
6949 assert!(!forge.calls().contains(&"merge"));
6950 let why = state.merge.as_ref().unwrap().detail.clone();
6951 assert!(
6952 why.contains("mine") && why.contains("someone-elses"),
6953 "{why}"
6954 );
6955 assert_eq!(state.status, RunStatus::Blocked);
6956 }
6957
6958 #[tokio::test]
6959 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6960 let mut state = landing_state();
6961 let forge = Scripted::new(
6962 vec![
6963 seen("a", Checks::Green, "CLEAN", false),
6964 seen("a", Checks::Green, "CLEAN", false),
6965 seen("a", Checks::Pending, "BLOCKED", false),
6966 seen("a", Checks::Pending, "BLOCKED", false),
6967 seen("a", Checks::Green, "CLEAN", false),
6968 ],
6969 vec![(false, REFUSED), (true, "")],
6970 );
6971 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6972 .await
6973 .unwrap();
6974 assert_eq!(
6975 forge.calls(),
6976 [
6977 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6978 "view"
6979 ]
6980 );
6981 assert_eq!(state.status, RunStatus::Merged);
6982 }
6983
6984 #[tokio::test]
6985 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6986 let mut state = landing_state();
6987 let forge = Scripted::new(
6988 vec![
6989 seen("a", Checks::Green, "CLEAN", false),
6990 seen("a", Checks::Green, "BLOCKED", false),
6991 ],
6992 vec![(false, REFUSED), (false, REFUSED)],
6993 );
6994 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6995 .await
6996 .unwrap();
6997 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6999 let why = state.merge.as_ref().unwrap().detail.clone();
7000 assert!(
7001 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7002 "{why}"
7003 );
7004 assert_eq!(state.status, RunStatus::Blocked);
7005 }
7006
7007 #[test]
7008 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7009 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7010 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7011 assert_eq!(bound_head("old", "old", Some("new")), None);
7013 assert_eq!(bound_head("new", "old", Some("new")), None);
7015 assert_eq!(bound_head("new", "old", None), None);
7016 assert_eq!(bound_head("", "", None), None);
7018 assert_eq!(bound_head("", "", Some("new")), None);
7019 assert_eq!(bound_head("abc", "", None), None);
7020 }
7021
7022 fn view_json(head: &str) -> String {
7023 format!(
7024 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7025 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7026 "reviews":[],"comments":[]}}"#
7027 )
7028 }
7029
7030 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7031 format!(
7032 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7033 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7034 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7035 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7036 )
7037 }
7038
7039 #[test]
7040 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7041 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7044 assert_eq!(s.rollup_head, "old");
7045 assert_eq!(s.pr.checks, Checks::Red);
7046 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7047 assert_eq!(s.failing_urls.len(), 1);
7048 }
7049
7050 #[test]
7051 fn checks_come_from_the_node_not_the_view() {
7052 let view = view_json("new").replace(
7053 r#""reviews""#,
7054 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7055 );
7056 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7057 assert_eq!(s.pr.checks, Checks::Green);
7058 assert!(s.pr.failing.is_empty());
7059 assert_eq!(
7060 bound_head(&s.head, &s.rollup_head, Some("new")),
7061 Some("new")
7062 );
7063 }
7064
7065 #[test]
7066 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7067 for node in [
7068 None,
7069 Some("not json".to_owned()),
7070 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7071 Some(node_json("new", "SUCCESS", true)),
7072 ] {
7073 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7074 assert!(s.rollup_head.is_empty());
7075 assert_eq!(s.pr.checks, Checks::Unknown);
7076 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7077 }
7078 }
7079
7080 #[test]
7081 fn the_merge_command_is_pinned_to_the_observed_head() {
7082 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7083 let at = argv
7084 .iter()
7085 .position(|a| a == "--match-head-commit")
7086 .unwrap();
7087 assert_eq!(argv[at + 1], "deadbeef");
7088 }
7089
7090 #[tokio::test]
7091 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7092 let mut state = landing_state();
7093 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7096 stale.rollup_head = "old".to_owned();
7097 let forge = Scripted::new(
7098 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7099 vec![],
7100 );
7101 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7102 head: "new".to_owned(),
7103 });
7104 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7105 .await
7106 .unwrap();
7107 assert!(!forge.calls().contains(&"merge"));
7108 assert_eq!(state.status, RunStatus::Blocked);
7109 let why = state.merge.as_ref().unwrap().detail.clone();
7110 assert!(why.contains("new") && why.contains("old"), "{why}");
7111 }
7112
7113 #[test]
7114 fn a_refusal_read_against_another_commits_checks_is_pending() {
7115 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7116 after.rollup_head = "old".to_owned();
7117 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7118 }
7119
7120 #[tokio::test]
7121 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7122 let mut state = landing_state();
7123 let forge = Scripted::new(
7124 vec![seen("a", Checks::Red, "CLEAN", false)],
7125 vec![(true, "")],
7126 );
7127 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7128 .await
7129 .unwrap();
7130 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7131 assert_eq!(state.status, RunStatus::Merged);
7132 }
7133
7134 #[tokio::test]
7135 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7136 let mut state = landing_state();
7137 let forge = Scripted::new(
7138 vec![
7139 seen("a", Checks::Green, "CLEAN", false),
7140 seen("a", Checks::Green, "CLEAN", false),
7141 seen("b", Checks::Green, "BLOCKED", false),
7143 seen("b", Checks::Green, "CLEAN", false),
7144 ],
7145 vec![(false, REFUSED), (true, "")],
7146 );
7147 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7148 .await
7149 .unwrap();
7150 assert_eq!(
7151 forge.calls(),
7152 [
7153 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7154 ]
7155 );
7156 assert_eq!(state.status, RunStatus::Merged);
7157 }
7158
7159 fn merged_view(head: &str) -> Seen {
7160 let mut m = seen(head, Checks::Green, "CLEAN", false);
7161 m.pr.state = PrLifecycle::Merged;
7162 m
7163 }
7164
7165 fn has(argv: &[String], flag: &str) -> bool {
7166 argv.iter().any(|a| a == flag)
7167 }
7168
7169 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7170 let at = argv.iter().position(|a| a == flag)?;
7171 argv.get(at + 1).map(String::as_str)
7172 }
7173
7174 const URL: &str = "https://github.com/o/r/pull/42";
7175
7176 #[tokio::test]
7177 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7178 let mut state = landing_state();
7179 let forge = Scripted::new(
7180 vec![
7181 seen("abc", Checks::Green, "CLEAN", false),
7182 seen("abc", Checks::Green, "CLEAN", false),
7183 ],
7184 vec![(true, "")],
7185 );
7186 land_with(&mut state, URL, &forge).await.unwrap();
7187 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7188 let argv = &forge.argvs()[0];
7189 assert!(has(argv, "--squash") && has(argv, "--subject"));
7190 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7191 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7192 assert_eq!(state.status, RunStatus::Merged);
7193 assert!(state.land_armed_head.is_none());
7194 }
7195
7196 #[tokio::test]
7197 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7198 let mut state = landing_state();
7199 state.land_armed_head = Some("a".to_owned());
7200 let forge = Scripted::new(
7201 vec![seen("a", Checks::Green, "CLEAN", false)],
7202 vec![(true, ""), (true, "")],
7203 );
7204 land_with(&mut state, URL, &forge).await.unwrap();
7205 let argvs = forge.argvs();
7206 assert!(has(&argvs[0], "--disable-auto"));
7207 assert!(!has(&argvs[1], "--auto"));
7208 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7209 assert_eq!(state.status, RunStatus::Merged);
7210 assert!(state.land_armed_head.is_none());
7211 }
7212
7213 #[tokio::test]
7214 async fn an_approval_never_carries_over_to_a_new_head() {
7215 crate::run::pin_test_home();
7216 let mut state = run_state();
7217 state.config.graph.land_approval = true;
7218 let pr = green_pr();
7219 let store = ask::Questions::open();
7220
7221 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7222 .await
7223 .unwrap();
7224 let mut q = store
7225 .list()
7226 .into_iter()
7227 .find(|q| q.run == state.id)
7228 .unwrap();
7229 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7230 store.put(&mut q).unwrap();
7231 assert_eq!(
7232 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7233 .await
7234 .unwrap(),
7235 ApprovalGate::Approved,
7236 "the same head keeps its approval"
7237 );
7238
7239 assert_eq!(
7241 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7242 .await
7243 .unwrap(),
7244 ApprovalGate::Pending
7245 );
7246 let all: Vec<_> = store
7247 .list()
7248 .into_iter()
7249 .filter(|q| q.run == state.id)
7250 .collect();
7251 assert_eq!(all.len(), 2);
7252
7253 state.land_approval = None;
7255 assert_eq!(
7256 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7257 .await
7258 .unwrap(),
7259 ApprovalGate::Pending
7260 );
7261 let open = store
7262 .list()
7263 .into_iter()
7264 .filter(|q| q.run == state.id && q.status.open())
7265 .count();
7266 assert_eq!(open, 1, "the superseded question was retired");
7267 }
7268
7269 #[tokio::test]
7270 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7271 let mut state = landing_state();
7272 let forge = Scripted::new(
7273 vec![
7274 seen("a", Checks::Green, "CLEAN", false),
7275 seen("b", Checks::Green, "CLEAN", false),
7277 ],
7278 vec![(true, "")],
7279 );
7280 land_with(&mut state, URL, &forge).await.unwrap();
7281 let argvs = forge.argvs();
7282 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7283 assert!(!has(&argvs[0], "--auto"));
7284 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7285 assert_eq!(state.status, RunStatus::Merged);
7286 }
7287
7288 fn passing(label: &str) -> CheckInfo {
7289 CheckInfo {
7290 label: label.to_owned(),
7291 verdict: Verdict::Pass,
7292 required: Some(false),
7293 run: None,
7294 }
7295 }
7296
7297 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7298 (label.to_owned(), run.map(str::to_owned))
7299 }
7300
7301 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7302 LandReruns {
7303 head: head.to_owned(),
7304 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7305 }
7306 }
7307
7308 #[test]
7309 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7310 let f = [failing("test (macos)", Some("7"))];
7311 assert_eq!(
7312 rerun_step(&f, None, "h", 100),
7313 RerunStep::Rerun(vec!["7".to_owned()])
7314 );
7315 let rec = reran("h", &[("7", 100)]);
7316 let grace = crate::release_watch::RERUN_GRACE;
7317 assert_eq!(
7318 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7319 RerunStep::Grace
7320 );
7321 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7322 }
7323
7324 #[test]
7325 fn a_failure_without_a_run_id_goes_to_the_fixer() {
7326 let f = [failing("a", Some("7")), failing("ci/external", None)];
7327 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7328 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7329 }
7330
7331 #[test]
7332 fn a_moved_head_forgets_the_reruns() {
7333 let f = [failing("a", Some("7"))];
7334 let rec = reran("old", &[("7", 100)]);
7335 assert_eq!(
7336 rerun_step(&f, Some(&rec), "new", 101),
7337 RerunStep::Rerun(vec!["7".to_owned()])
7338 );
7339 }
7340
7341 #[test]
7342 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7343 let f = [
7344 failing("a", Some("7")),
7345 failing("b", Some("7")),
7346 failing("c", Some("8")),
7347 ];
7348 assert_eq!(
7349 rerun_step(&f, None, "h", 0),
7350 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7351 );
7352 let rec = reran("h", &[("7", 0)]);
7353 assert_eq!(
7354 rerun_step(&f, Some(&rec), "h", 1),
7355 RerunStep::Rerun(vec!["8".to_owned()])
7356 );
7357 }
7358
7359 #[test]
7360 fn only_blocking_failures_are_candidates_for_a_rerun() {
7361 let check = |label: &str, verdict, required, run: &str| CheckInfo {
7362 label: label.to_owned(),
7363 verdict,
7364 required,
7365 run: Some(run.to_owned()),
7366 };
7367 let got = rerunnable(&[
7368 check("passed", Verdict::Pass, Some(true), "1"),
7369 check("optional", Verdict::Fail, Some(false), "2"),
7370 check("required", Verdict::Fail, Some(true), "3"),
7371 check("unknown", Verdict::Fail, None, "4"),
7372 ]);
7373 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7374 assert_eq!(labels, ["required", "unknown"]);
7375 }
7376
7377 fn red(head: &str, run: Option<&str>) -> Seen {
7378 let mut s = seen(head, Checks::Red, "BLOCKED", false);
7379 s.pr.failing = vec!["test (macos)".to_owned()];
7380 s.contexts = vec![CheckInfo {
7381 label: "test (macos)".to_owned(),
7382 verdict: Verdict::Fail,
7383 required: Some(true),
7384 run: run.map(str::to_owned),
7385 }];
7386 s
7387 }
7388
7389 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7390
7391 #[tokio::test]
7392 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7393 let mut state = landing_state();
7394 let forge = Scripted::new(
7395 vec![
7396 red("a", Some("7")),
7397 seen("a", Checks::Pending, "BLOCKED", false),
7398 seen("a", Checks::Green, "CLEAN", false),
7399 ],
7400 vec![(true, "")],
7401 );
7402 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7403 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7404 assert!(!forge.calls().contains(&"fix"));
7405 assert_eq!(state.status, RunStatus::Merged);
7406 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7407 let argvs = forge.argvs();
7408 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7409 assert!(
7410 state
7411 .events
7412 .iter()
7413 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7414 );
7415 }
7416
7417 #[tokio::test]
7418 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7419 let mut state = landing_state();
7420 let forge = Scripted::new(
7421 vec![
7422 red("a", Some("7")),
7423 seen("a", Checks::Green, "CLEAN", false),
7424 ],
7425 vec![(true, "")],
7426 );
7427 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7428 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7430 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7431 let calls = forge.calls();
7432 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7433 let fix_at = calls.iter().position(|c| *c == "fix");
7434 assert!(fix_at.is_none_or(|f| f > rerun_at));
7435 }
7436
7437 #[tokio::test]
7438 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7439 let mut state = landing_state();
7440 let mut v = red("a", Some("7"));
7441 v.pr.checks = Checks::Pending;
7442 v.contexts.push(CheckInfo {
7443 label: "slow".to_owned(),
7444 verdict: Verdict::Pending,
7445 required: Some(true),
7446 run: Some("8".to_owned()),
7447 });
7448 let forge = Scripted::new(
7449 vec![v, seen("a", Checks::Green, "CLEAN", false)],
7450 vec![(true, "")],
7451 );
7452 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7453 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7454 assert_eq!(state.status, RunStatus::Merged);
7455 }
7456
7457 #[test]
7458 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7459 let mut v = red("a", Some("7"));
7460 v.contexts.push(CheckInfo {
7461 label: "same run, still going".to_owned(),
7462 verdict: Verdict::Pending,
7463 required: Some(true),
7464 run: Some("7".to_owned()),
7465 });
7466 assert!(rerunnable(&v.contexts).is_empty());
7467 }
7468
7469 #[tokio::test]
7470 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7471 let mut state = landing_state();
7472 state.config.graph.land_rounds = 0;
7473 let forge = Scripted::new(
7474 vec![
7475 red("a", Some("7")),
7476 seen("a", Checks::Green, "CLEAN", false),
7477 ],
7478 vec![(true, "")],
7479 );
7480 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7481 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7482 assert_eq!(state.status, RunStatus::Merged);
7483 assert!(!forge.calls().contains(&"fix"));
7484 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7485 }
7486
7487 #[tokio::test]
7488 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7489 let mut state = landing_state();
7490 state.config.graph.land_rounds = 1;
7491 let forge = Scripted::new(
7492 vec![
7493 seen("a", Checks::Green, "CLEAN", true),
7494 red("b", Some("7")),
7495 seen("b", Checks::Green, "CLEAN", false),
7496 ],
7497 vec![(true, "")],
7498 );
7499 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7500 head: "b".to_owned(),
7501 });
7502 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7503 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7504 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7505 assert_eq!(state.pr.as_ref().unwrap().round, 1);
7506 assert_eq!(state.status, RunStatus::Merged);
7507 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7508 assert_eq!(
7509 value_of(&forge.argvs()[0], "--match-head-commit"),
7510 Some("b")
7511 );
7512 }
7513
7514 #[tokio::test]
7515 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7516 let mut state = landing_state();
7517 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7518 *forge.rerun_fails.lock().unwrap() = true;
7519 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7520 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7521 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7522 assert_eq!(
7523 forge.calls().iter().filter(|c| **c == "fix").count(),
7524 1,
7525 "{:?}",
7526 forge.calls()
7527 );
7528 }
7529
7530 #[tokio::test]
7531 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7532 let mut state = landing_state();
7533 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7534 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7536 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7537 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7538 assert!(forge.reruns.lock().unwrap().is_empty());
7539 }
7540
7541 #[tokio::test]
7542 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7543 let mut state = landing_state();
7544 state.config.graph.rerun_flaky_checks = false;
7545 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7546 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7547 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7548 assert!(forge.reruns.lock().unwrap().is_empty());
7549 assert!(forge.calls().contains(&"fix"));
7550
7551 let mut state = landing_state();
7552 let forge = Scripted::new(vec![red("a", None)], vec![]);
7553 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7554 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7555 assert!(forge.reruns.lock().unwrap().is_empty());
7556 assert!(forge.calls().contains(&"fix"));
7557 }
7558
7559 fn names(xs: &[&str]) -> BTreeSet<String> {
7560 xs.iter().map(|x| (*x).to_owned()).collect()
7561 }
7562
7563 #[test]
7564 fn a_required_check_the_rollup_never_listed_is_named() {
7565 let req = names(&["build"]);
7566 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7567 assert!(why.contains("never reported: build"), "{why}");
7568 assert!(!why.contains("probably waiting for a review"), "{why}");
7569 }
7570
7571 #[test]
7572 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7573 let why = waiting_on("BLOCKED", &[passing("review")], None);
7574 assert!(why.contains("could not be read"), "{why}");
7575 assert!(!why.contains("probably waiting for a review"), "{why}");
7576 }
7577
7578 #[test]
7579 fn all_required_reported_keeps_the_review_guess() {
7580 let req = names(&["build"]);
7581 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7582 assert!(why.contains("probably waiting for a review"), "{why}");
7583 assert!(!why.contains("never reported"), "{why}");
7584 }
7585
7586 #[test]
7587 fn required_names_match_the_rollup_ignoring_case_only() {
7588 let req = names(&["Build"]);
7589 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7590 assert!(!why.contains("never reported"), "{why}");
7591 }
7592
7593 #[test]
7594 fn required_contexts_are_read_from_protection_and_rulesets() {
7595 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7596 assert_eq!(
7597 parse_classic_required(classic),
7598 Some(names(&["build", "lint"]))
7599 );
7600 let rules = r#"[{"type":"pull_request","parameters":{}},
7601 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7602 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7603 assert_eq!(parse_ruleset_required("nope"), None);
7604 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7605 }
7606
7607 #[test]
7608 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7609 let shown = BTreeSet::new();
7610 let ok = seen("a", Checks::Green, "CLEAN", false);
7611 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7612 assert!(guard(Some(&ok)));
7613 assert!(!guard(None));
7614 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7615 let mut stale = ok.clone();
7616 stale.rollup_head = "old".to_owned();
7617 assert!(!guard(Some(&stale)));
7618 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7619 assert!(!guard(Some(&merged_view("a"))));
7620 }
7621
7622 #[test]
7623 fn the_rollup_node_carries_whether_each_check_is_required() {
7624 let node = node_json("new", "SUCCESS", false)
7625 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7626 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7627 assert_eq!(s.contexts.len(), 1);
7628 assert_eq!(s.contexts[0].required, Some(true));
7629 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7630 assert_eq!(s.contexts[0].required, None);
7631 }
7632
7633 #[tokio::test]
7634 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7635 let mut state = landing_state();
7636 state.land_armed_head = Some("a".to_owned());
7637 let forge = Scripted::new(
7638 vec![seen("a", Checks::Green, "CLEAN", true)],
7639 vec![(false, "disable exploded")],
7640 );
7641 land_with(&mut state, URL, &forge).await.unwrap();
7642 assert!(!forge.calls().contains(&"fix"));
7643 assert_eq!(state.status, RunStatus::Blocked);
7644 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7645 let why = state.merge.as_ref().unwrap().detail.clone();
7646 assert!(why.contains("disable exploded"), "{why}");
7647 }
7648}