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<()> {
2159 edit_pr_title(repo, url, title).await
2160}
2161
2162pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2165 let (ok, _) = gh(
2166 repo,
2167 &[
2168 "pr".to_owned(),
2169 "edit".to_owned(),
2170 url.to_owned(),
2171 "--title".to_owned(),
2172 title.to_owned(),
2173 "--body".to_owned(),
2174 body.to_owned(),
2175 ],
2176 )
2177 .await
2178 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2179 if !ok {
2180 bail!("gh pr edit failed while posting the chosen rewrite");
2182 }
2183 Ok(())
2184}
2185
2186async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2187 let (ok, out) = gh(
2188 repo,
2189 &[
2190 "pr".to_owned(),
2191 "edit".to_owned(),
2192 url.to_owned(),
2193 "--title".to_owned(),
2194 title.to_owned(),
2195 ],
2196 )
2197 .await?;
2198 if !ok {
2199 bail!("gh pr edit failed: {out}");
2200 }
2201 Ok(())
2202}
2203
2204pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2217 let Some(winner) = state.winner() else {
2218 return Ok(None);
2219 };
2220 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2221 let out = gh(
2222 &state.repo,
2223 &[
2224 "pr".to_owned(),
2225 "list".to_owned(),
2226 "--head".to_owned(),
2227 branch.clone(),
2228 "--state".to_owned(),
2229 "merged".to_owned(),
2230 "--json".to_owned(),
2231 "url,number,mergedAt,baseRefName".to_owned(),
2232 ],
2233 )
2234 .await?;
2235 if !out.0 {
2236 bail!("gh pr list --head {branch}: {}", out.1);
2237 }
2238 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2239}
2240
2241pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2253 let out = tokio::process::Command::new("git")
2254 .args(["merge-base", "--is-ancestor", branch, base_branch])
2255 .current_dir(repo)
2256 .quiet()
2257 .stdin(std::process::Stdio::null())
2258 .output()
2259 .await
2260 .context("spawn git merge-base --is-ancestor")?;
2261 Ok(out.status.success())
2262}
2263
2264fn forge_slug(url: &str) -> Option<(String, &str)> {
2272 let rest = url.rsplit("://").next()?;
2273 let (host, path) = rest.split_once('/')?;
2274 if host.is_empty() {
2275 return None;
2276 }
2277 Some((host.to_ascii_lowercase(), path))
2278}
2279
2280pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2288 let (host, path) = forge_slug(url)?;
2289 let mut segments = path.split('/');
2290 let owner = segments.next()?;
2291 let repo = segments.next()?;
2292 let kind = segments.next()?;
2293 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2294 return None;
2295 }
2296 Some(format!("{host}/{owner}/{repo}"))
2297}
2298
2299fn slug_of_repo_url(url: &str) -> Option<String> {
2303 let (host, path) = forge_slug(url)?;
2304 let mut segments = path.split('/');
2305 let owner = segments.next()?;
2306 let repo = segments.next()?;
2307 if owner.is_empty() || repo.is_empty() {
2308 return None;
2309 }
2310 Some(format!("{host}/{owner}/{repo}"))
2311}
2312
2313pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2334 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2335 return Ok(());
2336 }
2337 bail!(
2338 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2339 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2340 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2341 verify by hand before treating this as a false positive)"
2342 );
2343}
2344
2345async fn repo_slug(repo: &Path) -> Result<String> {
2356 let out = gh(
2357 repo,
2358 &[
2359 "repo".to_owned(),
2360 "view".to_owned(),
2361 "--json".to_owned(),
2362 "url".to_owned(),
2363 ],
2364 )
2365 .await?;
2366 if !out.0 {
2367 bail!("gh repo view --json url: {}", out.1);
2368 }
2369 #[derive(Debug, Deserialize)]
2370 struct GhRepo {
2371 url: String,
2372 }
2373 let parsed: GhRepo = serde_json::from_str(&out.1)
2374 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2375 slug_of_repo_url(&parsed.url)
2376 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2377}
2378
2379pub async fn correct_manual_merge(
2427 state: &mut RunState,
2428 url: &str,
2429) -> Result<(RunStatus, RunStatus)> {
2430 let Some(pr_slug) = slug_of_pr_url(url) else {
2431 bail!(
2432 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2433 this pull request belongs to"
2434 );
2435 };
2436 let run_slug = repo_slug(&state.repo).await?;
2437 ensure_same_repo(&run_slug, &pr_slug)?;
2438 correct_merge(state, url).await
2439}
2440
2441pub(crate) async fn correct_confirmed_external_merge(
2452 state: &mut RunState,
2453 url: &str,
2454) -> Result<(RunStatus, RunStatus)> {
2455 correct_merge(state, url).await
2456}
2457
2458fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2460 let Some(pr) = a.pr.as_ref() else {
2461 return false;
2462 };
2463 if !url.is_empty()
2464 && pr
2465 .url
2466 .trim_end_matches('/')
2467 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2468 {
2469 return true;
2470 }
2471 number > 0
2472 && pr.number == number
2473 && match (a.repo.canonicalize(), repo.canonicalize()) {
2474 (Ok(x), Ok(y)) => x == y,
2475 _ => a.repo == repo,
2476 }
2477}
2478
2479fn rewrite_open_prs(
2492 home: &Path,
2493 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2494) -> usize {
2495 let now = Timestamp::now();
2496 let mut changed = 0;
2497 for id in crate::run::list_ids_in(&home.join("runs")) {
2498 let path = home.join("runs").join(&id).join("run.json");
2499 let Ok(body) = std::fs::read_to_string(&path) else {
2500 continue;
2501 };
2502 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2503 continue;
2504 };
2505 if !state.status.done()
2506 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2507 || crate::daemon::is_working_on(home, &id, now)
2508 {
2509 continue;
2510 }
2511 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2512 continue;
2513 };
2514 if let Some(pr) = state.pr.as_mut() {
2515 pr.state = to.as_str().to_owned();
2516 }
2517 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2518 state.event(
2519 "land",
2520 format!("recorded {url} as {}: another run settled it", to.as_str()),
2521 );
2522 match state.save_under(home) {
2523 Ok(()) => changed += 1,
2524 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2525 }
2526 }
2527 changed
2528}
2529
2530pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2535 if to == PrLifecycle::Open {
2536 return;
2537 }
2538 let Some(home) = crate::run::try_home() else {
2539 return;
2540 };
2541 write_pr_state_through_in(&home, run, to);
2542}
2543
2544pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2545 let Some(pr) = run.pr.as_ref() else {
2546 return 0;
2547 };
2548 let (url, number) = (pr.url.clone(), pr.number);
2549 rewrite_open_prs(home, &mut |other| {
2550 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2551 })
2552}
2553
2554pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2557 let now = Timestamp::now();
2558 let mut out = Vec::new();
2559 for id in crate::run::list_ids_in(&home.join("runs")) {
2560 let path = home.join("runs").join(&id).join("run.json");
2561 let Ok(body) = std::fs::read_to_string(&path) else {
2562 continue;
2563 };
2564 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2565 continue;
2566 };
2567 if let Some(pr) = state.pr.as_ref()
2568 && state.status.done()
2569 && pr.state == "open"
2570 && !pr.url.is_empty()
2571 && !crate::daemon::is_working_on(home, &id, now)
2572 {
2573 out.push((id, state.repo.clone(), pr.url.clone()));
2574 }
2575 }
2576 out
2577}
2578
2579pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2582 rewrite_open_prs(home, &mut |s| {
2583 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2584 })
2585}
2586
2587pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2594 let mut known = BTreeMap::new();
2595 let mut failed = 0;
2596 let mut seen = BTreeSet::new();
2597 for (_, repo, url) in stale_open_prs(home) {
2598 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2599 continue;
2600 }
2601 match lifecycle(&repo, &url).await {
2602 Ok(state) => {
2603 known.insert(url, state);
2604 }
2605 Err(e) => {
2606 tracing::warn!("repair pr state of {url}: {e:#}");
2607 failed += 1;
2608 }
2609 }
2610 }
2611 (apply_pr_states(home, &known), failed)
2612}
2613
2614async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2615 match lifecycle(&state.repo, url).await? {
2616 PrLifecycle::Merged => {}
2617 other => bail!(
2618 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2619 other.as_str(),
2620 state.id
2621 ),
2622 }
2623 let before = state.status;
2624 if let Err(e) = land(state, url).await {
2625 state.status = RunStatus::Blocked;
2632 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2633 state.save()?;
2634 return Err(e).context(format!("confirming the merge of {url}"));
2635 }
2636 state.event(
2637 "fold",
2638 "operator recorded this pull request as a manual merge; this run never \
2639 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2640 bump this change might warrant has to be filed by hand",
2641 );
2642 if state.status == RunStatus::Merged {
2645 crate::followup::after_merge(state, url).await;
2646 }
2647 state.save()?;
2648 Ok((before, state.status))
2649}
2650
2651pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2658 let raw: Vec<GhInline> =
2659 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2660 let mut out = Vec::new();
2661 for c in raw {
2662 push_if_outstanding(
2663 &mut out,
2664 ReviewComment {
2665 author: c.user.login,
2666 path: c.path,
2667 line: c.line,
2668 body: c.body,
2669 },
2670 );
2671 }
2672 Ok(out)
2673}
2674
2675fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2681 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2682 return;
2683 }
2684 if comment.path.is_none() && is_noise(&comment.body) {
2685 return;
2686 }
2687 out.push(comment);
2688}
2689
2690pub fn is_noise(body: &str) -> bool {
2708 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2709 return true;
2710 }
2711 let mut content = false;
2712 for line in strip_blocks(body).lines() {
2713 let line = unquote(line);
2714 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2715 continue;
2716 }
2717 content = true;
2718 break;
2719 }
2720 !content
2721}
2722
2723fn strip_blocks(body: &str) -> String {
2725 let mut out = String::with_capacity(body.len());
2726 let mut rest = body;
2727 loop {
2728 let open = ["<!--", "<details>"]
2729 .iter()
2730 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2731 .min_by_key(|(i, _)| *i);
2732 let Some((at, tag)) = open else {
2733 out.push_str(rest);
2734 return out;
2735 };
2736 out.push_str(&rest[..at]);
2737 let after = &rest[at + tag.len()..];
2738 let close = if tag == "<!--" { "-->" } else { "</details>" };
2739 match after.find(close) {
2740 Some(end) => rest = &after[end + close.len()..],
2741 None => return out,
2743 }
2744 }
2745}
2746
2747fn unquote(line: &str) -> &str {
2749 let mut s = line.trim();
2750 while let Some(rest) = s.strip_prefix('>') {
2751 s = rest.trim_start();
2752 }
2753 s.trim()
2754}
2755
2756fn is_checklist(line: &str) -> bool {
2758 let rest = line
2759 .strip_prefix("- ")
2760 .or_else(|| line.strip_prefix("* "))
2761 .unwrap_or("");
2762 let rest = rest.trim_start();
2763 matches!(
2764 rest.get(..3),
2765 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2766 )
2767}
2768
2769fn is_decoration(line: &str) -> bool {
2771 line.starts_with('#')
2772 || line.starts_with("[!")
2773 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2774}
2775
2776fn is_banner(line: &str) -> bool {
2783 let plain = drop_spans(line, "**", "**");
2784 let plain = if plain.contains("](") {
2785 drop_spans(&plain, "[", ")")
2786 } else {
2787 plain
2788 };
2789 !plain.chars().any(char::is_alphanumeric)
2790}
2791
2792fn drop_spans(s: &str, open: &str, close: &str) -> String {
2796 let mut out = String::with_capacity(s.len());
2797 let mut rest = s;
2798 while let Some(at) = rest.find(open) {
2799 out.push_str(&rest[..at]);
2800 let after = &rest[at + open.len()..];
2801 match after.find(close) {
2802 Some(end) => rest = &after[end + close.len()..],
2803 None => return out,
2804 }
2805 }
2806 out.push_str(rest);
2807 out
2808}
2809
2810fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2828 static LOCKS: std::sync::LazyLock<
2829 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2830 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2831 LOCKS
2832 .lock()
2833 .unwrap_or_else(std::sync::PoisonError::into_inner)
2834 .entry(repo.to_path_buf())
2835 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2836 .clone()
2837}
2838
2839fn repo_label(repo: &Path, pr_url: &str) -> String {
2842 let parts: Vec<&str> = pr_url.split('/').collect();
2843 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2844 && at >= 2
2845 && !parts[at - 1].is_empty()
2846 && !parts[at - 2].is_empty()
2847 {
2848 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2849 }
2850 repo.file_name()
2851 .map(|n| n.to_string_lossy().into_owned())
2852 .unwrap_or_default()
2853}
2854
2855fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2859 (pr.checks == Checks::Red).then(|| {
2860 format!(
2861 "Merged {repo_name} PR #{} with red checks: {} ({})",
2862 pr.number,
2863 if pr.failing.is_empty() {
2864 "(none named)".to_owned()
2865 } else {
2866 pr.failing.join(", ")
2867 },
2868 pr.url
2869 )
2870 })
2871}
2872
2873async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2877 let repo_name = repo_label(&state.repo, &pr.url);
2878 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2879 return;
2880 };
2881 if let Some(rec) = state.pr.as_mut() {
2882 rec.red_at_merge = pr.failing.clone();
2883 }
2884 state.event("land", summary.clone());
2885 crate::notices::raise_with(
2888 crate::notices::merged_red(&state.id, &summary),
2889 &state.config.notify,
2890 );
2891}
2892
2893pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2900 land_with(state, pr_url, &GhForge).await
2901}
2902
2903trait Forge {
2907 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2908 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2909 async fn poll(&self);
2910 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
2912 fn now(&self) -> i64;
2914 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2917 #[allow(clippy::too_many_arguments)]
2918 async fn fix(
2919 &self,
2920 state: &mut RunState,
2921 pr: &PrState,
2922 round: usize,
2923 budget: usize,
2924 reason: &str,
2925 logs: &str,
2926 ) -> Result<Fixed>;
2927}
2928
2929struct GhForge;
2930
2931impl Forge for GhForge {
2932 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2933 observe(repo, pr_url).await
2934 }
2935 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2936 gh(repo, argv).await
2937 }
2938 async fn poll(&self) {
2939 tokio::time::sleep(POLL).await;
2940 }
2941 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
2942 let args = [
2943 "run".to_owned(),
2944 "rerun".to_owned(),
2945 run.to_owned(),
2946 "--failed".to_owned(),
2947 ];
2948 let (ok, out) = gh(repo, &args).await?;
2949 if !ok {
2950 bail!("gh run rerun {run}: {out}");
2951 }
2952 Ok(())
2953 }
2954 fn now(&self) -> i64 {
2955 jiff::Timestamp::now().as_second()
2956 }
2957 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2958 required_contexts_of(repo, base).await
2959 }
2960 async fn fix(
2961 &self,
2962 state: &mut RunState,
2963 pr: &PrState,
2964 round: usize,
2965 budget: usize,
2966 reason: &str,
2967 logs: &str,
2968 ) -> Result<Fixed> {
2969 fix_round(state, pr, round, budget, reason, logs).await
2970 }
2971}
2972
2973fn encode_path_segment(s: &str) -> String {
2975 let mut out = String::new();
2976 for b in s.bytes() {
2977 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2978 out.push(b as char);
2979 } else {
2980 let _ = write!(out, "%{b:02X}");
2981 }
2982 }
2983 out
2984}
2985
2986async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2991 let enc = encode_path_segment(base);
2992 let mut all = BTreeSet::new();
2993 let classic = gh(
2996 repo,
2997 &[
2998 "api".to_owned(),
2999 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3000 ],
3001 )
3002 .await
3003 .ok()?;
3004 if classic.0 {
3005 all.extend(parse_classic_required(&classic.1)?);
3006 } else if !classic.1.contains("404") {
3007 return None;
3008 }
3009 let rules = gh(
3010 repo,
3011 &[
3012 "api".to_owned(),
3013 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3014 ],
3015 )
3016 .await
3017 .ok()?;
3018 if !rules.0 {
3019 return None;
3020 }
3021 all.extend(parse_ruleset_required(&rules.1)?);
3022 Some(all)
3023}
3024
3025fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3028 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3029 let mut out = BTreeSet::new();
3030 for c in v.get("contexts")?.as_array()? {
3031 out.insert(c.as_str()?.to_owned());
3032 }
3033 for c in v
3034 .get("checks")
3035 .and_then(|c| c.as_array())
3036 .into_iter()
3037 .flatten()
3038 {
3039 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3040 out.insert(name.to_owned());
3041 }
3042 }
3043 Some(out)
3044}
3045
3046fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3048 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3049 let mut out = BTreeSet::new();
3050 for rule in v.as_array()? {
3051 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3052 continue;
3053 }
3054 let checks = rule
3055 .pointer("/parameters/required_status_checks")?
3056 .as_array()?;
3057 for c in checks {
3058 out.insert(c.get("context")?.as_str()?.to_owned());
3059 }
3060 }
3061 Some(out)
3062}
3063
3064fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3072 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3073}
3074
3075fn bound_head<'a>(
3085 seen_head: &'a str,
3086 rollup_head: &str,
3087 awaiting: Option<&str>,
3088) -> Option<&'a str> {
3089 if seen_head.is_empty()
3090 || awaiting_new_head(awaiting, seen_head)
3091 || !rollup_head.eq_ignore_ascii_case(seen_head)
3092 {
3093 return None;
3094 }
3095 Some(seen_head)
3096}
3097
3098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3100enum Refused {
3101 Pending,
3103 Recheck,
3107 Final,
3110}
3111
3112fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3115 let Some(after) = after else {
3116 return Refused::Pending;
3118 };
3119 if after.pr.state != PrLifecycle::Open {
3120 return Refused::Final;
3121 }
3122 if !after.head.eq_ignore_ascii_case(observed_head) {
3125 return Refused::Pending;
3126 }
3127 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3129 return Refused::Pending;
3130 }
3131 let state = after.merge_state.to_ascii_uppercase();
3132 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3133 || state.is_empty()
3134 || state == "UNKNOWN"
3135 {
3136 return Refused::Pending;
3137 }
3138 if rechecked {
3139 Refused::Final
3140 } else {
3141 Refused::Recheck
3142 }
3143}
3144
3145async fn disarm<F: Forge>(
3150 forge: &F,
3151 state: &mut RunState,
3152 repo: &Path,
3153 number: u64,
3154) -> std::result::Result<(), String> {
3155 let argv = disable_automerge_argv(number);
3156 let out = {
3157 let merge_lock = repo_merge_lock(repo);
3158 let _merge_slot = merge_lock.lock().await;
3159 forge.merge(repo, &argv).await
3160 };
3161 match out {
3162 Ok((true, _)) => {
3163 state.land_armed_head = None;
3164 state.event("land", "auto-merge disabled");
3165 state.save().map_err(|e| format!("{e:#}"))?;
3166 Ok(())
3167 }
3168 Ok((false, msg)) => Err(msg),
3169 Err(e) => Err(format!("{e:#}")),
3170 }
3171}
3172
3173async fn stop_disarmed<F: Forge>(
3177 forge: &F,
3178 state: &mut RunState,
3179 repo: &Path,
3180 pr: &PrState,
3181 why: &str,
3182) -> Result<()> {
3183 if state.land_armed_head.is_none() {
3184 return stop(state, repo, pr, why).await;
3185 }
3186 match disarm(forge, state, repo, pr.number).await {
3187 Ok(()) => stop(state, repo, pr, why).await,
3188 Err(e) => {
3189 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3190 stop(state, repo, pr, &why).await
3191 }
3192 }
3193}
3194
3195async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3196 crate::pr_head::ensure_owner(state)?;
3197 let repo = state.repo.clone();
3198 let budget = state.config.graph.land_rounds;
3199 let mut round = 0usize;
3200 let mut rebases = 0usize;
3203 let mut waited = Duration::ZERO;
3204 let mut shown: BTreeSet<String> = BTreeSet::new();
3209 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3211 let mut rechecked = false;
3213
3214 state.status = RunStatus::Landing;
3222 state.event("land", format!("watching {pr_url}"));
3223 state.save()?;
3224
3225 let mut resumed_armed = state.land_armed_head.is_some();
3229
3230 loop {
3231 let seen = forge.view(&repo, pr_url).await?;
3232 let mut pr = seen.pr.clone();
3233 pr.review_comments.retain(|c| !shown.contains(&c.body));
3234 state.pr = Some(crate::run::PrRecord {
3235 url: pr.url.clone(),
3236 number: pr.number,
3237 state: pr.state.as_str().to_owned(),
3238 checks: pr.checks.as_str().to_owned(),
3239 round,
3240 rounds: budget,
3241 red_at_merge: Vec::new(),
3242 });
3243 state.save()?;
3244
3245 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3246 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3251 let why = format!(
3252 "a previous pass may have armed auto-merge and it could not be disabled \
3253 on resume: {e}"
3254 );
3255 stop(state, &repo, &pr, &why).await?;
3256 return Ok(pr);
3257 }
3258 }
3259
3260 if pr.state == PrLifecycle::Open
3265 && !seen.head.is_empty()
3266 && state
3267 .land_armed_head
3268 .as_deref()
3269 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3270 {
3271 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3272 let why = format!(
3273 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3274 );
3275 stop(state, &repo, &pr, &why).await?;
3276 return Ok(pr);
3277 }
3278 }
3279
3280 if pr.state == PrLifecycle::Open {
3281 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3282 if waited >= WAIT_CEILING {
3283 let want = awaiting_head.as_deref().unwrap_or_default();
3284 let why = format!(
3285 "the pull request's checks were still not about one readable head after \
3286 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3287 someone may have pushed over it",
3288 WAIT_CEILING.as_secs() / 60,
3289 if want.is_empty() { "any" } else { want },
3290 if seen.head.is_empty() {
3291 "nothing readable"
3292 } else {
3293 &seen.head
3294 },
3295 if seen.rollup_head.is_empty() {
3296 "nothing readable"
3297 } else {
3298 &seen.rollup_head
3299 },
3300 );
3301 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3302 return Ok(pr);
3303 }
3304 waited += POLL;
3305 forge.poll().await;
3306 continue;
3307 }
3308 if awaiting_head.take().is_some() {
3311 waited = Duration::ZERO;
3314 }
3315 }
3316
3317 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3322 let failing = rerunnable(&seen.contexts);
3323 let now = forge.now();
3324 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3325 RerunStep::Rerun(runs) => {
3326 let rec = state.land_reruns.get_or_insert_with(Default::default);
3327 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3328 *rec = LandReruns {
3329 head: seen.head.clone(),
3330 runs: BTreeMap::new(),
3331 };
3332 }
3333 for run in &runs {
3334 rec.runs.insert(run.clone(), now);
3335 }
3336 state.save()?;
3340 let mut any = false;
3341 for run in &runs {
3342 let names = failing
3343 .iter()
3344 .filter(|(_, r)| r.as_deref() == Some(run))
3345 .map(|(l, _)| l.as_str())
3346 .collect::<Vec<_>>()
3347 .join(", ");
3348 match forge.rerun(&repo, run).await {
3349 Ok(()) => {
3350 any = true;
3351 state.event(
3352 "land",
3353 format!("reran failed check(s) {names} (run {run})"),
3354 );
3355 }
3356 Err(e) => state.event(
3358 "land",
3359 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3360 ),
3361 }
3362 }
3363 state.save()?;
3364 if any {
3365 waited = Duration::ZERO;
3366 forge.poll().await;
3367 continue;
3368 }
3369 }
3370 RerunStep::Grace => {
3371 if waited >= WAIT_CEILING {
3372 let why = format!(
3373 "checks were still failing after a rerun and {} minutes",
3374 WAIT_CEILING.as_secs() / 60
3375 );
3376 stop(state, &repo, &pr, &why).await?;
3377 return Ok(pr);
3378 }
3379 waited += POLL;
3380 forge.poll().await;
3381 continue;
3382 }
3383 RerunStep::Fix => {}
3384 }
3385 }
3386
3387 let step = decide(&pr, round, budget, waited);
3388 let armed_here = state
3393 .land_armed_head
3394 .as_deref()
3395 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3396 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3397 if waited >= WAIT_CEILING {
3398 let required = if seen.base.is_empty() {
3399 None
3400 } else {
3401 forge.required_contexts(&repo, &seen.base).await
3402 };
3403 let why = format!(
3404 "auto-merge was armed on {} but the pull request did not merge within {} \
3405 minutes ({})",
3406 seen.head,
3407 WAIT_CEILING.as_secs() / 60,
3408 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3409 );
3410 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3411 return Ok(pr);
3412 }
3413 waited += POLL;
3414 forge.poll().await;
3415 continue;
3416 }
3417 if armed_here && !matches!(step, Step::Done { .. }) {
3418 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3421 let why = format!("auto-merge could not be disabled: {e}");
3422 stop(state, &repo, &pr, &why).await?;
3423 return Ok(pr);
3424 }
3425 }
3426 match step {
3427 Step::Wait => {
3428 if waited >= WAIT_CEILING {
3429 let why = format!(
3430 "checks were still running after {} minutes",
3431 WAIT_CEILING.as_secs() / 60
3432 );
3433 stop(state, &repo, &pr, &why).await?;
3434 return Ok(pr);
3435 }
3436 waited += POLL;
3437 forge.poll().await;
3438 }
3439 Step::Done { merged } => {
3440 if let Some(armed) = state.land_armed_head.take() {
3445 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3446 let msg = format!(
3447 "{} merged on {} but the owner approved {armed}; review what landed",
3448 pr.url, seen.head
3449 );
3450 tracing::warn!("{msg}");
3451 state.event("land", msg);
3452 crate::notices::raise_with(
3456 crate::notices::Notice::warn(
3457 &format!("merged-unapproved-head:{}", state.id),
3458 "A pull request merged on a commit the owner did not approve; \
3459 review what landed",
3460 )
3461 .link(crate::notices::Link::Run {
3462 id: state.id.clone(),
3463 }),
3464 &state.config.notify,
3465 );
3466 }
3467 }
3468 state.status = if merged {
3469 RunStatus::Merged
3470 } else {
3471 RunStatus::Ready
3472 };
3473 let detail = if merged {
3474 format!("{} was merged", pr.url)
3475 } else {
3476 format!("{} was closed without merging", pr.url)
3477 };
3478 state.merge = Some(MergeOutcome {
3479 mode: MergeMode::Pr,
3480 ok: merged,
3481 detail: detail.clone(),
3482 empty: false,
3483 });
3484 state.event("land", detail);
3485 state.save()?;
3486 write_pr_state_through(state, pr.state);
3487 return Ok(pr);
3488 }
3489 Step::Merge => {
3490 let subject = merge_subject(
3491 crate::graph::landing_title(state, &seen.title),
3492 &crate::graph::landing_subject_source(state),
3493 );
3494 let contested = contested_to_ask(state);
3501 if state.config.graph.land_approval || contested.is_some() {
3502 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3503 .await?
3504 {
3505 ApprovalGate::Approved => {}
3506 ApprovalGate::Held => {
3507 stop(
3508 state,
3509 &repo,
3510 &pr,
3511 "the owner did not approve the merge (held or unanswered)",
3512 )
3513 .await?;
3514 return Ok(pr);
3515 }
3516 ApprovalGate::Pending => {
3524 state.end_pass_parked(crate::run::ParkCause::Question);
3525 state.parked = true;
3526 state.event(
3527 "land",
3528 "parked awaiting merge approval - resumes once answered",
3529 );
3530 state.save()?;
3531 return Ok(pr);
3532 }
3533 }
3534 }
3535 let observed_head = seen.head.clone();
3546 {
3549 let fresh = forge.view(&repo, pr_url).await.ok();
3550 if !direct_merge_is_safe(
3551 fresh.as_ref(),
3552 &observed_head,
3553 &shown,
3554 round,
3555 budget,
3556 waited,
3557 ) {
3558 if waited >= WAIT_CEILING {
3559 let why = "the pull request did not settle on the approved head \
3560 before it could be merged";
3561 stop(state, &repo, &pr, why).await?;
3562 return Ok(pr);
3563 }
3564 state.event(
3565 "land",
3566 "the pull request changed before merging; looking again",
3567 );
3568 waited += POLL;
3569 forge.poll().await;
3570 continue;
3571 }
3572 }
3573 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3574 let out = {
3575 let merge_lock = repo_merge_lock(&repo);
3576 let _merge_slot = merge_lock.lock().await;
3577 forge.merge(&repo, &argv).await?
3578 };
3579 if out.0 {
3580 let confirmed = forge
3586 .view(&repo, pr_url)
3587 .await
3588 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3589 if !confirmed {
3590 if waited >= WAIT_CEILING {
3591 let why = "the merge request succeeded but the pull request \
3592 could not be confirmed merged after waiting";
3593 stop(state, &repo, &pr, why).await?;
3594 return Ok(pr);
3595 }
3596 state.event(
3597 "land",
3598 "merge accepted but the pull request is not confirmed merged yet; waiting",
3599 );
3600 state.save()?;
3601 waited += POLL;
3602 forge.poll().await;
3603 continue;
3604 }
3605 pr.state = PrLifecycle::Merged;
3606 state.status = RunStatus::Merged;
3607 state.merge = Some(MergeOutcome {
3608 mode: MergeMode::Pr,
3609 ok: true,
3610 detail: format!("gh {}", argv.join(" ")),
3611 empty: false,
3612 });
3613 if let Some(pr_record) = state.pr.as_mut() {
3618 pr_record.state = pr.state.as_str().to_owned();
3619 }
3620 state.event("land", format!("merged {} as `{subject}`", pr.url));
3621 announce_red_merge(state, &pr).await;
3622 state.save()?;
3623 write_pr_state_through(state, pr.state);
3624 return Ok(pr);
3625 }
3626 let after_seen = forge.view(&repo, pr_url).await.ok();
3627 let after = after_seen.as_ref().map(|s| s.pr.state);
3628 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3629 pr.state = PrLifecycle::Merged;
3630 state.status = RunStatus::Merged;
3631 state.merge = Some(outcome);
3632 if let Some(pr_record) = state.pr.as_mut() {
3633 pr_record.state = pr.state.as_str().to_owned();
3634 }
3635 state.event("land", format!("merged {} as `{subject}`", pr.url));
3636 announce_red_merge(state, &pr).await;
3637 state.save()?;
3638 write_pr_state_through(state, pr.state);
3639 return Ok(pr);
3640 }
3641 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3642 match verdict {
3643 Refused::Final => {
3644 let merge_state = after_seen
3645 .as_ref()
3646 .map(|s| s.merge_state.as_str())
3647 .filter(|m| !m.is_empty())
3648 .unwrap_or("unknown");
3649 let why = format!(
3653 "the merge was refused: {} (merge state: {merge_state})",
3654 out.1
3655 );
3656 stop(state, &repo, &pr, &why).await?;
3657 return Ok(pr);
3658 }
3659 verdict => {
3660 if waited >= WAIT_CEILING {
3665 let why = format!(
3666 "the merge was still refused after {} minutes: {}",
3667 WAIT_CEILING.as_secs() / 60,
3668 out.1
3669 );
3670 stop(state, &repo, &pr, &why).await?;
3671 return Ok(pr);
3672 }
3673 if verdict == Refused::Recheck {
3674 rechecked = true;
3675 }
3676 state.event(
3677 "land",
3678 "merge refused while the branch policy is not satisfied yet; waiting",
3679 );
3680 state.save()?;
3681 waited += POLL;
3682 forge.poll().await;
3683 }
3684 }
3685 }
3686 Step::Rebase => {
3687 if rebases >= budget {
3693 let why = format!(
3694 "the base moved under this branch {budget} time(s) and it still does \
3695 not merge; rebasing again would only race it"
3696 );
3697 stop(state, &repo, &pr, &why).await?;
3698 return Ok(pr);
3699 }
3700 rebases += 1;
3701 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3702 stop(
3703 state,
3704 &repo,
3705 &pr,
3706 "the pull request conflicts and this run has no winning branch to rebase",
3707 )
3708 .await?;
3709 return Ok(pr);
3710 };
3711 let base = state.base_branch.clone();
3712 state.event(
3713 "land",
3714 format!("{} no longer merges; rebasing onto {base}", pr.url),
3715 );
3716 state.save()?;
3717
3718 let remote = state.config.merge.remote.clone();
3722 git::fetch(&repo, &remote, &base).await.ok();
3723 let scratch = state.dir().join("rebase");
3724 let onto = format!("{remote}/{base}");
3725 let rebased =
3726 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3727 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3728 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3729 Err(e) => Err(e),
3730 };
3731 match rebased {
3732 Ok(None) => {
3733 let pushed = {
3734 let merge_lock = repo_merge_lock(&repo);
3735 let _merge_slot = merge_lock.lock().await;
3736 if let Some(head) = &state.pr_head {
3737 git::push_as(
3738 &repo,
3739 &state.config.merge.remote,
3740 &branch,
3741 head,
3742 state
3743 .pr_pushed_head
3744 .as_deref()
3745 .context("missing public head lease")?,
3746 )
3747 .await?
3748 } else {
3749 git::push_pinned(
3750 &repo,
3751 &state.config.merge.remote,
3752 &branch,
3753 &seen.head,
3754 )
3755 .await?
3756 }
3757 };
3758 if !pushed.ok() {
3759 let why = format!(
3760 "rebased {branch} but could not push it: {}",
3761 pushed.stderr.trim()
3762 );
3763 stop(state, &repo, &pr, &why).await?;
3764 return Ok(pr);
3765 }
3766 let head =
3769 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3770 Ok(head) => head,
3771 Err(e) => {
3772 let why = format!(
3773 "rebased and pushed {branch} but could not read the pushed \
3774 commit: {e:#}"
3775 );
3776 stop(state, &repo, &pr, &why).await?;
3777 return Ok(pr);
3778 }
3779 };
3780 crate::graph::refresh_reviewed_commits(state, &branch).await;
3781 state.pr_pushed_head = Some(head.clone());
3782 awaiting_head = Some(head);
3783 rechecked = false;
3784 state.event("land", format!("rebased {branch} onto {base}"));
3785 state.save()?;
3786 waited = Duration::ZERO;
3789 tokio::time::sleep(POLL).await;
3790 }
3791 Ok(Some(conflict)) => {
3794 let why = format!(
3795 "{} conflicts with {base} and the rebase did not apply: {}",
3796 pr.url,
3797 conflict.chars().take(600).collect::<String>()
3798 );
3799 stop(state, &repo, &pr, &why).await?;
3800 return Ok(pr);
3801 }
3802 Err(e) => {
3803 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3804 stop(state, &repo, &pr, &why).await?;
3805 return Ok(pr);
3806 }
3807 }
3808 }
3809 Step::GiveUp { reason } => {
3810 stop(state, &repo, &pr, &reason).await?;
3811 return Ok(pr);
3812 }
3813 Step::Fix { reason } => {
3814 round += 1;
3815 waited = Duration::ZERO;
3816 for c in &pr.review_comments {
3817 shown.insert(c.body.clone());
3818 }
3819 state.event("land", format!("round {round}: {reason}"));
3820 state.save()?;
3821
3822 let logs = failing_logs(&repo, &seen.failing_urls).await;
3823 let was_red = pr.checks == Checks::Red;
3824 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3825 Fixed::Committed { head } => {
3826 state.pr_pushed_head = Some(head.clone());
3829 awaiting_head = Some(head);
3830 rechecked = false;
3831 waited = Duration::ZERO;
3832 forge.poll().await;
3833 }
3834 Fixed::Declined if was_red => {
3835 let why = format!(
3836 "the fixer produced no commit while {} check(s) were failing \
3837 ({}); stopping instead of looping on an unchanged tree",
3838 pr.failing.len(),
3839 pr.failing.join(", ")
3840 );
3841 stop(state, &repo, &pr, &why).await?;
3842 return Ok(pr);
3843 }
3844 Fixed::Declined => state.event(
3849 "land",
3850 format!("round {round}: fixer declined the comments, nothing committed"),
3851 ),
3852 Fixed::Failed(why) => {
3853 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3854 return Ok(pr);
3855 }
3856 }
3857 state.save()?;
3858 }
3859 }
3860 }
3861}
3862
3863#[derive(Clone)]
3867struct Seen {
3868 pr: PrState,
3869 title: String,
3870 failing_urls: Vec<(String, String)>,
3871 head: String,
3873 rollup_head: String,
3876 merge_state: String,
3879 contexts: Vec<CheckInfo>,
3881 base: String,
3883}
3884
3885#[derive(Clone)]
3887struct CheckInfo {
3888 label: String,
3889 verdict: Verdict,
3890 required: Option<bool>,
3891 run: Option<String>,
3893}
3894
3895#[derive(Debug, Clone, PartialEq, Eq)]
3897enum RerunStep {
3898 Rerun(Vec<String>),
3900 Grace,
3902 Fix,
3904}
3905
3906fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
3910 contexts
3911 .iter()
3912 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
3913 .filter(|c| {
3915 c.run.as_ref().is_none_or(|r| {
3916 !contexts.iter().any(|o| {
3917 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
3918 && o.run.as_ref() == Some(r)
3919 })
3920 })
3921 })
3922 .map(|c| (c.label.clone(), c.run.clone()))
3923 .collect()
3924}
3925
3926fn rerun_step(
3928 failing: &[(String, Option<String>)],
3929 record: Option<&LandReruns>,
3930 head: &str,
3931 now: i64,
3932) -> RerunStep {
3933 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
3936 return RerunStep::Fix;
3937 }
3938 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
3940 let mut fresh: Vec<String> = Vec::new();
3941 let mut recent = false;
3942 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
3943 match record.and_then(|r| r.runs.get(run)) {
3944 None => {
3945 if !fresh.iter().any(|f| f == run) {
3946 fresh.push(run.to_owned());
3947 }
3948 }
3949 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
3950 Some(_) => {}
3951 }
3952 }
3953 if !fresh.is_empty() {
3954 RerunStep::Rerun(fresh)
3955 } else if recent {
3956 RerunStep::Grace
3957 } else {
3958 RerunStep::Fix
3959 }
3960}
3961
3962async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3966 let view = gh(
3967 repo,
3968 &[
3969 "pr".to_owned(),
3970 "view".to_owned(),
3971 pr_url.to_owned(),
3972 "--json".to_owned(),
3973 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3974 .to_owned(),
3975 ],
3976 )
3977 .await?;
3978 if !view.0 {
3979 bail!("gh pr view {pr_url}: {}", view.1);
3980 }
3981 let number = parse_pr(&view.1)?.number;
3982 let node = last_commit_node(repo, number).await;
3983 let mut seen = seen_from(&view.1, node.as_deref())?;
3984
3985 let inline = gh(
3986 repo,
3987 &[
3988 "api".to_owned(),
3989 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3990 ],
3991 )
3992 .await?;
3993 if inline.0 {
3994 match parse_inline_comments(&inline.1) {
3995 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3996 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3999 }
4000 } else {
4001 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4002 }
4003 Ok(seen)
4004}
4005
4006fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4017 let mut pr = parse_pr(view_json)?;
4018 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4019
4020 let mut rollup_head = String::new();
4021 let mut failing_urls = Vec::new();
4022 let mut contexts = Vec::new();
4023 let mut checks = Checks::Unknown;
4024 let mut failing = Vec::new();
4025 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4026 (checks, failing) = rollup_verdict(&rollup);
4027 failing_urls = rollup
4028 .iter()
4029 .filter(|c| c.verdict() == Verdict::Fail)
4030 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4031 .collect();
4032 contexts = rollup
4033 .iter()
4034 .map(|c| CheckInfo {
4035 label: c.label(),
4036 verdict: c.verdict(),
4037 required: c.is_required,
4038 run: c.url().and_then(run_of),
4039 })
4040 .collect();
4041 rollup_head = oid;
4042 }
4043 pr.checks = checks;
4044 pr.failing = failing;
4045
4046 Ok(Seen {
4047 pr,
4048 title: raw.title,
4049 failing_urls,
4050 head: raw.head_ref_oid,
4051 rollup_head,
4052 merge_state: raw.merge_state_status,
4053 contexts,
4054 base: raw.base_ref_name,
4055 })
4056}
4057
4058fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4061 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4062 if v.get("errors").is_some_and(|e| !e.is_null()) {
4063 return None;
4064 }
4065 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4066 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4067 let contexts = commit.pointer("/statusCheckRollup/contexts");
4068 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4069 return Some((oid.to_owned(), Vec::new()));
4071 };
4072 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4073 return None;
4074 }
4075 let nodes = contexts.get("nodes")?.as_array()?;
4076 let rollup = nodes
4077 .iter()
4078 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4079 .collect::<Result<Vec<_>, _>>()
4080 .ok()?;
4081 Some((oid.to_owned(), rollup))
4082}
4083
4084async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4087 let out = gh(
4088 repo,
4089 &[
4090 "api".to_owned(),
4091 "graphql".to_owned(),
4092 "-F".to_owned(),
4093 "owner={owner}".to_owned(),
4094 "-F".to_owned(),
4095 "repo={repo}".to_owned(),
4096 "-F".to_owned(),
4097 format!("number={number}"),
4098 "-f".to_owned(),
4099 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4100 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4101 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4102 ... on CheckRun{name status conclusion detailsUrl \
4103 isRequired(pullRequestNumber:$number)} \
4104 ... on StatusContext{context state targetUrl \
4105 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4106 .to_owned(),
4107 ],
4108 )
4109 .await
4110 .ok()?;
4111 out.0.then_some(out.1)
4112}
4113
4114#[doc(hidden)]
4116#[derive(Debug, PartialEq)]
4117pub enum Fixed {
4118 Committed {
4120 head: String,
4122 },
4123 Declined,
4125 Failed(String),
4127}
4128
4129#[doc(hidden)]
4135pub async fn fix_round(
4136 state: &mut RunState,
4137 pr: &PrState,
4138 round: usize,
4139 budget: usize,
4140 reason: &str,
4141 logs: &str,
4142) -> Result<Fixed> {
4143 let winner = state
4144 .winner()
4145 .cloned()
4146 .context("landing needs a winning candidate; none is recorded on this run")?;
4147 let roles = state
4148 .config
4149 .resolve_roles()
4150 .context("resolve the roster for the fix round")?;
4151 let attempts = crate::fixer::attempts(state, &roles, &winner);
4155 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4156
4157 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4158 let artifacts = agent::artifacts_dir(&state.dir());
4159 let prompt = if state.config.cache_dir().is_some() {
4160 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4161 } else {
4162 prompt
4163 };
4164 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4168 let mut last = None;
4172 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4173 let mut seat = seat_of(state, &seat_key, &spec.id);
4174 let stem = if i == 0 {
4175 format!("land-{round}")
4176 } else {
4177 format!("land-{round}-{}", spec.id)
4178 };
4179 let out = agent::invoke(
4180 &spec,
4181 &mut seat,
4182 &Invocation {
4183 cwd: &winner.worktree,
4184 prompt: &prompt,
4185 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4186 allow_write: true,
4187 unsandboxed: false,
4188 sessions: state.config.graph.sessions,
4189 artifacts: &artifacts,
4190 stem: &stem,
4191 run: &state.id,
4192 node: "land",
4193 cache_dir: state.config.cache_dir().as_deref(),
4194 attachments: &[],
4195 writable: &[],
4196 },
4197 )
4198 .await;
4199 state.seats.insert(seat.key.clone(), seat);
4200 if let Some(next) = ids.get(i + 1)
4201 && agent::chain_advances(&out)
4202 {
4203 let (class, why) = crate::fixer::failure_of(&out);
4204 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4205 continue;
4206 }
4207 last = Some(out);
4208 break;
4209 }
4210 let out = last.expect("the fixer chain always has an entry");
4211
4212 match out {
4213 Ok(o) if o.quota_exhausted() => {
4214 return Ok(Fixed::Failed(
4215 "rate limited (quota); the fixer could not run".to_owned(),
4216 ));
4217 }
4218 Ok(o) if !o.usable() => {
4219 return Ok(Fixed::Failed(format!(
4220 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4221 o.exit_code, o.timed_out
4222 )));
4223 }
4224 Ok(_) => {}
4225 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4226 }
4227
4228 if let Ok(r) = git::rescue_commit(
4231 &winner.worktree,
4232 &format!("magi: land round {round} fixes (uncommitted work)"),
4233 )
4234 .await
4235 {
4236 state.note_withheld("land", &r.withheld);
4237 }
4238 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4239 if after == before {
4240 return Ok(Fixed::Declined);
4241 }
4242
4243 let remote = state.config.merge.remote.clone();
4244 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4245 let push = if state.pr_head.is_some() {
4246 let expected = state
4247 .pr_pushed_head
4248 .as_deref()
4249 .context("missing public head lease")?;
4250 git::push_as(
4251 &winner.worktree,
4252 &remote,
4253 &winner.branch,
4254 &public_head,
4255 expected,
4256 )
4257 .await?
4258 } else {
4259 git::push(&winner.worktree, &remote, &winner.branch).await?
4260 };
4261 if !push.ok() {
4262 return Ok(Fixed::Failed(format!(
4263 "pushing {} to {remote} failed: {}",
4264 public_head, push.stderr
4265 )));
4266 }
4267 state.event(
4268 "land",
4269 format!("round {round}: pushed a fix to {public_head}"),
4270 );
4271 state.pr_pushed_head = Some(after.clone());
4272 state.save()?;
4273 Ok(Fixed::Committed { head: after })
4274}
4275
4276pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4278 crate::fixer::seat_for(state, key, agent)
4279}
4280
4281fn fix_prompt(
4283 state: &RunState,
4284 pr: &PrState,
4285 round: usize,
4286 budget: usize,
4287 reason: &str,
4288 logs: &str,
4289) -> String {
4290 let mut s = format!(
4291 "Your patch is open as a pull request and it is not landing. Land round \
4292 {round} of {budget}.\n\n\
4293 Pull request: {}\n\n\
4294 What is holding it: {reason}\n\n\
4295 # The task\n\n{}\n",
4296 pr.url, state.instruction
4297 );
4298
4299 if pr.failing.is_empty() {
4300 s.push_str("\n# Failing checks\n\n(none)\n");
4301 } else {
4302 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4303 if logs.trim().is_empty() {
4304 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4305 } else {
4306 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4307 }
4308 }
4309
4310 if pr.review_comments.is_empty() {
4311 s.push_str("\n# Review comments\n\n(none)\n");
4312 } else {
4313 s.push_str("\n# Review comments\n");
4314 for c in &pr.review_comments {
4315 let where_ = match (&c.path, c.line) {
4316 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4317 (Some(p), None) => format!(" ({p})"),
4318 _ => String::new(),
4319 };
4320 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4321 }
4322 }
4323
4324 s.push_str(
4325 "\n# Rules\n\n\
4326 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4327 failing test; do not silence a lint with an allow attribute; do not \
4328 stretch a timeout to hide a race. If the check is right, the code is \
4329 wrong.\n\
4330 2. Change nothing the checks and the comments did not raise. A \
4331 drive-by refactor turns a one-line fix into a pull request that \
4332 needs reviewing again.\n\
4333 3. If a comment is wrong, say so with a checkable argument and change \
4334 nothing for it. A declined comment with a reason is a correct \
4335 outcome; a change made to appease a reviewer is not.\n\
4336 4. Commit in this worktree. magi pushes to the pull request's branch \
4337 for you; do not push, merge, or close anything yourself.\n\
4338 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4339 # Output\n\n\
4340 Say what you changed and why, and what you declined and why.",
4341 );
4342
4343 let language = &state.config.graph.language;
4344 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4345 let _ = write!(s, "\n\nWrite all prose in {language}.");
4346 }
4347 s.push_str(&crate::prompt::github_english(language));
4349 if let Some(overlay) = state.config.prompts.overlay("fix") {
4350 let _ = write!(s, "\n\n{overlay}");
4351 }
4352 s
4353}
4354
4355async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4358 let mut out = String::new();
4359 for (name, url) in failing.iter().take(MAX_LOGS) {
4360 let args = match (job_of(url), run_of(url)) {
4361 (Some(job), _) => vec![
4362 "run".to_owned(),
4363 "view".to_owned(),
4364 "--log-failed".to_owned(),
4365 "--job".to_owned(),
4366 job,
4367 ],
4368 (None, Some(run)) => vec![
4369 "run".to_owned(),
4370 "view".to_owned(),
4371 run,
4372 "--log-failed".to_owned(),
4373 ],
4374 (None, None) => continue,
4376 };
4377 let (ok, body) = match gh(repo, &args).await {
4378 Ok(v) => v,
4379 Err(e) => (false, format!("{e:#}")),
4380 };
4381 if !ok && body.trim().is_empty() {
4382 continue;
4383 }
4384 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4385 }
4386 out
4387}
4388
4389fn job_of(details_url: &str) -> Option<String> {
4392 let after = details_url.split("/job/").nth(1)?;
4393 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4394 (!id.is_empty()).then_some(id)
4395}
4396
4397pub(crate) fn run_of(details_url: &str) -> Option<String> {
4399 let after = details_url.split("/actions/runs/").nth(1)?;
4400 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4401 (!id.is_empty()).then_some(id)
4402}
4403
4404fn stop_comment(run_id: &str, why: &str) -> String {
4408 format!(
4409 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4410 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4411 )
4412}
4413
4414async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4419 let body = stop_comment(&state.id, why);
4420 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4421 if !body.contains(MARKER) {
4422 body = format!("{MARKER}\n{body}");
4423 }
4424 let posted = gh(
4425 repo,
4426 &[
4427 "pr".to_owned(),
4428 "comment".to_owned(),
4429 pr.number.to_string(),
4430 "--body".to_owned(),
4431 body,
4432 ],
4433 )
4434 .await;
4435 match posted {
4436 Ok((true, _)) => {}
4437 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4438 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4439 }
4440 state.status = RunStatus::Blocked;
4441 state.merge = Some(MergeOutcome {
4442 mode: MergeMode::Pr,
4443 ok: false,
4444 detail: why.to_owned(),
4445 empty: false,
4446 });
4447 state.event("land", format!("stopped: {why}"));
4448 state.save()?;
4449 Ok(())
4450}
4451
4452pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4468 let out = tokio::process::Command::new("gh")
4469 .args(args)
4470 .current_dir(cwd)
4471 .env_remove("GH_REPO")
4472 .quiet()
4473 .stdin(std::process::Stdio::null())
4474 .output()
4475 .await
4476 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4477 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4478 let err = String::from_utf8_lossy(&out.stderr);
4479 if body.trim().is_empty() {
4480 body = err.into_owned();
4481 } else if !err.trim().is_empty() {
4482 body.push_str(&err);
4483 }
4484 Ok((out.status.success(), body.trim().to_owned()))
4485}
4486
4487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4489pub(crate) enum Verdict {
4490 Pass,
4491 Fail,
4492 Pending,
4493 Unknown,
4494}
4495
4496#[derive(Debug, Deserialize)]
4497#[serde(rename_all = "camelCase")]
4498struct GhPr {
4499 #[serde(default)]
4500 url: String,
4501 #[serde(default)]
4502 number: u64,
4503 #[serde(default)]
4504 state: String,
4505 #[serde(default)]
4506 title: String,
4507 #[serde(default)]
4508 status_check_rollup: Vec<GhCheck>,
4509 #[serde(default)]
4516 merge_state_status: String,
4517 #[serde(default)]
4521 head_ref_oid: String,
4522 #[serde(default)]
4523 base_ref_name: String,
4524 #[serde(default)]
4525 reviews: Vec<GhReview>,
4526 #[serde(default)]
4527 comments: Vec<GhComment>,
4528}
4529
4530#[derive(Debug, Deserialize)]
4535#[serde(rename_all = "camelCase")]
4536struct GhCheck {
4537 #[serde(default)]
4538 name: Option<String>,
4539 #[serde(default)]
4540 context: Option<String>,
4541 #[serde(default)]
4542 status: Option<String>,
4543 #[serde(default)]
4544 conclusion: Option<String>,
4545 #[serde(default)]
4546 state: Option<String>,
4547 #[serde(default)]
4548 details_url: Option<String>,
4549 #[serde(default)]
4550 target_url: Option<String>,
4551 #[serde(default)]
4554 is_required: Option<bool>,
4555}
4556
4557impl GhCheck {
4558 fn label(&self) -> String {
4560 self.name
4561 .clone()
4562 .or_else(|| self.context.clone())
4563 .unwrap_or_else(|| "(unnamed check)".to_owned())
4564 }
4565
4566 fn url(&self) -> Option<&str> {
4568 self.details_url
4569 .as_deref()
4570 .or(self.target_url.as_deref())
4571 .filter(|u| !u.is_empty())
4572 }
4573
4574 fn verdict(&self) -> Verdict {
4582 if let Some(status) = self.status.as_deref() {
4583 if !status.eq_ignore_ascii_case("COMPLETED") {
4584 return Verdict::Pending;
4585 }
4586 }
4587 let outcome = self
4588 .conclusion
4589 .as_deref()
4590 .or(self.state.as_deref())
4591 .unwrap_or("");
4592 match outcome.to_ascii_uppercase().as_str() {
4593 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4594 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4595 | "ACTION_REQUIRED" => Verdict::Fail,
4596 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4597 Verdict::Pending
4598 }
4599 _ => Verdict::Unknown,
4600 }
4601 }
4602}
4603
4604#[derive(Debug, Deserialize)]
4605struct GhAuthor {
4606 #[serde(default)]
4607 login: String,
4608}
4609
4610#[derive(Debug, Deserialize)]
4611struct GhReview {
4612 #[serde(default)]
4613 author: GhAuthor,
4614 #[serde(default)]
4615 body: String,
4616}
4617
4618#[derive(Debug, Deserialize)]
4619struct GhComment {
4620 #[serde(default)]
4621 author: GhAuthor,
4622 #[serde(default)]
4623 body: String,
4624}
4625
4626#[derive(Debug, Deserialize)]
4627struct GhUser {
4628 #[serde(default)]
4629 login: String,
4630}
4631
4632#[derive(Debug, Deserialize)]
4633struct GhInline {
4634 #[serde(default)]
4635 user: GhUser,
4636 #[serde(default)]
4637 path: Option<String>,
4638 #[serde(default)]
4639 line: Option<u64>,
4640 #[serde(default)]
4641 body: String,
4642}
4643
4644impl Default for GhAuthor {
4645 fn default() -> Self {
4646 Self {
4647 login: "(unknown)".to_owned(),
4648 }
4649 }
4650}
4651
4652impl Default for GhUser {
4653 fn default() -> Self {
4654 Self {
4655 login: "(unknown)".to_owned(),
4656 }
4657 }
4658}
4659
4660#[cfg(test)]
4661mod tests {
4662 use super::*;
4663 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4664
4665 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4666 format!(
4667 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4668 )
4669 }
4670
4671 #[test]
4672 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4673 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4674 assert_eq!(
4675 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4676 Ok(())
4677 );
4678 for (json, why) in [
4679 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4680 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4681 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4682 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4683 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4684 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4685 ] {
4686 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4687 .unwrap_err()
4688 .why;
4689 assert!(err.contains(why), "{json}: {err}");
4690 }
4691 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4693 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4694 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4695 assert!(
4696 !closable(
4697 &head_json("x", "main", "OPEN", false),
4698 "magi/27b2/A",
4699 "main",
4700 &[]
4701 )
4702 .unwrap_err()
4703 .retry
4704 );
4705 assert!(is_forge_url("https://github.com/o/r.git"));
4706 assert!(is_forge_url("git@github.com:o/r.git"));
4707 assert!(!is_forge_url("/tmp/origin.git"));
4708 assert!(!is_forge_url("C:\\work\\origin.git"));
4709 assert!(!is_forge_url("file:///tmp/origin.git"));
4710 assert!(forge_unavailable(
4711 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4712 ));
4713 assert!(!forge_unavailable(
4714 "gh pr list failed: error connecting to api.github.com"
4715 ));
4716 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4717 assert!(
4719 closable(
4720 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4721 "b",
4722 "main",
4723 &["aaa".to_owned()]
4724 )
4725 .is_err()
4726 );
4727 }
4728
4729 #[test]
4730 fn the_close_comment_names_the_commit_on_the_base() {
4731 let e = crate::already::Evidence {
4732 proof: crate::already::Proof::PatchId,
4733 tip: "1234567890".to_owned(),
4734 commits: vec!["0e368de0000".to_owned()],
4735 };
4736 let c = superseded_comment("main", &e);
4737 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4738 }
4739
4740 const GREEN_OPEN: &str = r####"{
4742 "url": "https://github.com/yukimemi/magi/pull/10",
4743 "number": 10,
4744 "state": "OPEN",
4745 "mergeStateStatus": "CLEAN",
4746 "statusCheckRollup": [
4747 {
4748 "__typename": "CheckRun",
4749 "conclusion": "SKIPPED",
4750 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4751 "name": "review",
4752 "status": "COMPLETED",
4753 "workflowName": "claude-review"
4754 },
4755 {
4756 "__typename": "CheckRun",
4757 "conclusion": "SUCCESS",
4758 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4759 "name": "check (ubuntu-latest)",
4760 "status": "COMPLETED",
4761 "workflowName": "CI"
4762 },
4763 {
4764 "__typename": "CheckRun",
4765 "conclusion": "SUCCESS",
4766 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4767 "name": "rustfmt",
4768 "status": "COMPLETED",
4769 "workflowName": "CI"
4770 },
4771 {
4772 "__typename": "StatusContext",
4773 "context": "CodeRabbit",
4774 "state": "SUCCESS",
4775 "targetUrl": ""
4776 }
4777 ],
4778 "reviews": [],
4779 "comments": [
4780 {
4781 "author": {
4782 "login": "coderabbitai"
4783 },
4784 "authorAssociation": "NONE",
4785 "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"
4786 }
4787 ]
4788}"####;
4789
4790 const RED_OPEN: &str = r####"{
4792 "url": "https://github.com/yukimemi/magi/pull/9",
4793 "number": 9,
4794 "state": "OPEN",
4795 "mergeStateStatus": "UNSTABLE",
4796 "statusCheckRollup": [
4797 {
4798 "__typename": "CheckRun",
4799 "conclusion": "SUCCESS",
4800 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4801 "name": "check (ubuntu-latest)",
4802 "status": "COMPLETED",
4803 "workflowName": "CI"
4804 },
4805 {
4806 "__typename": "CheckRun",
4807 "conclusion": "SUCCESS",
4808 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4809 "name": "rustfmt",
4810 "status": "COMPLETED",
4811 "workflowName": "CI"
4812 },
4813 {
4814 "__typename": "CheckRun",
4815 "conclusion": "FAILURE",
4816 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4817 "name": "editorconfig",
4818 "status": "COMPLETED",
4819 "workflowName": "CI"
4820 },
4821 {
4822 "__typename": "StatusContext",
4823 "context": "CodeRabbit",
4824 "state": "SUCCESS",
4825 "targetUrl": ""
4826 }
4827 ],
4828 "reviews": [],
4829 "comments": [
4830 {
4831 "author": {
4832 "login": "coderabbitai"
4833 },
4834 "authorAssociation": "NONE",
4835 "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"
4836 }
4837 ]
4838}"####;
4839
4840 const PENDING_OPEN: &str = r####"{
4842 "url": "https://github.com/yukimemi/magi/pull/9",
4843 "number": 9,
4844 "state": "OPEN",
4845 "statusCheckRollup": [
4846 {
4847 "__typename": "CheckRun",
4848 "conclusion": "SUCCESS",
4849 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4850 "name": "check (ubuntu-latest)",
4851 "status": "COMPLETED",
4852 "workflowName": "CI"
4853 },
4854 {
4855 "__typename": "CheckRun",
4856 "conclusion": "SUCCESS",
4857 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4858 "name": "rustfmt",
4859 "status": "COMPLETED",
4860 "workflowName": "CI"
4861 },
4862 {
4863 "__typename": "CheckRun",
4864 "conclusion": null,
4865 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4866 "name": "editorconfig",
4867 "status": "IN_PROGRESS",
4868 "workflowName": "CI"
4869 },
4870 {
4871 "__typename": "StatusContext",
4872 "context": "CodeRabbit",
4873 "state": "SUCCESS",
4874 "targetUrl": ""
4875 }
4876 ],
4877 "reviews": [],
4878 "comments": []
4879}"####;
4880
4881 const MERGED: &str = r####"{
4883 "url": "https://github.com/yukimemi/magi/pull/16",
4884 "number": 16,
4885 "state": "MERGED",
4886 "statusCheckRollup": [
4887 {
4888 "__typename": "CheckRun",
4889 "conclusion": "SUCCESS",
4890 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4891 "name": "check (ubuntu-latest)",
4892 "status": "COMPLETED",
4893 "workflowName": "CI"
4894 },
4895 {
4896 "__typename": "CheckRun",
4897 "conclusion": "SUCCESS",
4898 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4899 "name": "review",
4900 "status": "COMPLETED",
4901 "workflowName": "claude-review"
4902 }
4903 ],
4904 "reviews": [],
4905 "comments": []
4906}"####;
4907
4908 const REVIEWED_OPEN: &str = r####"{
4910 "url": "https://github.com/yukimemi/magi/pull/12",
4911 "number": 12,
4912 "state": "OPEN",
4913 "statusCheckRollup": [
4914 {
4915 "__typename": "CheckRun",
4916 "conclusion": "SUCCESS",
4917 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4918 "name": "check (ubuntu-latest)",
4919 "status": "COMPLETED",
4920 "workflowName": "CI"
4921 },
4922 {
4923 "__typename": "CheckRun",
4924 "conclusion": "SUCCESS",
4925 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4926 "name": "review",
4927 "status": "COMPLETED",
4928 "workflowName": "claude-review"
4929 }
4930 ],
4931 "reviews": [
4932 {
4933 "author": {
4934 "login": "claude"
4935 },
4936 "state": "COMMENTED",
4937 "body": ""
4938 }
4939 ],
4940 "comments": [
4941 {
4942 "author": {
4943 "login": "coderabbitai"
4944 },
4945 "authorAssociation": "NONE",
4946 "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"
4947 },
4948 {
4949 "author": {
4950 "login": "claude"
4951 },
4952 "authorAssociation": "NONE",
4953 "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"
4954 }
4955 ]
4956}"####;
4957
4958 const INLINE: &str = r####"[
4960 {
4961 "user": {
4962 "login": "claude[bot]"
4963 },
4964 "path": "src/graph.rs",
4965 "line": 231,
4966 "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"
4967 }
4968]"####;
4969
4970 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4972<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4973
4974> [!IMPORTANT]
4975> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4976>
4977> This repository does not receive automatic reviews because it has fewer than 10 stars.
4978>
4979> <details>
4980> <summary>⚙️ Run configuration</summary>
4981>
4982> **Configuration used**: defaults
4983>
4984> **Review profile**: CHILL
4985>
4986> **Plan**: Team
4987>
4988> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4989>
4990> </details>
4991
4992<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4993
4994<!-- tips_start -->
4995
4996---
4997
4998Thanks 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.
4999
5000<details>
5001<summary>❤️ Share</summary>
5002
5003- [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"####;
5004
5005 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5007
5008---
5009### Reviewing PR #16
5010
5011- [x] Read AGENTS.md conventions
5012- [x] Review `src/daemon.rs` changes
5013- [x] Review `src/main.rs` changes (new `doctor` reporting)
5014- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5015- [x] Check test coverage for new behavior
5016- [x] Run verification commands (blocked — see note)
5017- [x] Post findings"####;
5018
5019 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5021
5022---
5023### Review: `magi review <branch>` — cheap-half-only graph
5024
5025Read 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.
5026
5027**Correctness**
5028
5029- 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"####;
5030
5031 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5032 PrState {
5033 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5034 number: 16,
5035 state: PrLifecycle::Open,
5036 checks,
5037 blocking: if matches!(checks, Checks::Red) {
5041 Blocking::Yes
5042 } else {
5043 Blocking::No
5044 },
5045 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5046 review_comments: (0..comments)
5047 .map(|i| ReviewComment {
5048 author: "coderabbitai".to_owned(),
5049 path: Some("src/graph.rs".to_owned()),
5050 line: Some(231),
5051 body: format!("finding {i}"),
5052 })
5053 .collect(),
5054 }
5055 }
5056
5057 #[test]
5058 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5059 use CiExpectation::{Absent, Expected};
5060 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5061 let p = pr(checks, &["x"], 0);
5062 for waited in [Duration::ZERO, CHECKS_GRACE] {
5063 assert_eq!(
5064 decide_with(&p, 0, 4, waited, Expected),
5065 decide(&p, 0, 4, waited)
5066 );
5067 }
5068 }
5069 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5071 let p = pr(checks, &["x"], 0);
5072 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5073 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5074 }
5075 let mut p = pr(Checks::Unknown, &[], 0);
5077 p.blocking = Blocking::Conflict;
5078 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5079 p.state = PrLifecycle::Merged;
5081 assert_eq!(
5082 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5083 Step::Done { merged: true }
5084 );
5085 }
5086
5087 #[test]
5088 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5089 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5090 assert_eq!(state.number, 10);
5091 assert_eq!(state.state, PrLifecycle::Open);
5092 assert_eq!(state.checks, Checks::Green);
5093 assert!(state.failing.is_empty());
5094 assert!(
5095 state.review_comments.is_empty(),
5096 "the only comment is CodeRabbit's trigger notice: {:?}",
5097 state.review_comments
5098 );
5099 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5100 }
5101
5102 #[test]
5103 fn a_failing_check_parses_as_red_and_is_named() {
5104 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5105 assert_eq!(state.checks, Checks::Red);
5106 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5107 let mut blocking = state.clone();
5114 blocking.blocking = Blocking::Yes;
5115 match decide(&blocking, 0, 4, Duration::ZERO) {
5116 Step::Fix { reason } => {
5117 assert!(reason.contains("editorconfig"), "reason: {reason}");
5118 assert!(reason.contains("failing"), "reason: {reason}");
5119 }
5120 other => panic!("expected a fix round, got {other:?}"),
5121 }
5122 }
5123
5124 #[test]
5125 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5126 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5127 assert_eq!(state.checks, Checks::Pending);
5128 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5129 }
5130
5131 #[test]
5132 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5133 let state = parse_pr(MERGED).expect("merged fixture parses");
5134 assert_eq!(state.state, PrLifecycle::Merged);
5135 assert_eq!(
5136 decide(&state, 0, 4, Duration::ZERO),
5137 Step::Done { merged: true }
5138 );
5139 }
5140
5141 #[test]
5142 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5143 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5144 assert_eq!(state.checks, Checks::Green);
5145 let authors: Vec<&str> = state
5146 .review_comments
5147 .iter()
5148 .map(|c| c.author.as_str())
5149 .collect();
5150 assert_eq!(
5151 authors,
5152 vec!["claude"],
5153 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5154 );
5155 match decide(&state, 0, 4, Duration::ZERO) {
5156 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5157 other => panic!("expected a fix round, got {other:?}"),
5158 }
5159 }
5160
5161 #[test]
5162 fn inline_review_comments_keep_their_file_and_line() {
5163 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5164 assert_eq!(comments.len(), 1);
5165 assert_eq!(comments[0].author, "claude[bot]");
5166 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5167 assert_eq!(comments[0].line, Some(231));
5168 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5169 }
5170
5171 #[test]
5172 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5173 assert!(
5174 is_noise(CODERABBIT_TRIGGER),
5175 "CodeRabbit's trigger notice declares itself not a review"
5176 );
5177 assert!(
5178 is_noise(CLAUDE_CHECKLIST),
5179 "a progress checklist asks for nothing"
5180 );
5181 assert!(
5182 !is_noise(CLAUDE_FINDING),
5183 "a review that names a bug is input, not noise"
5184 );
5185
5186 let mut clean = pr(Checks::Green, &[], 0);
5187 clean.review_comments.push(ReviewComment {
5188 author: "coderabbitai".to_owned(),
5189 path: None,
5190 line: None,
5191 body: CODERABBIT_TRIGGER.to_owned(),
5192 });
5193 clean.review_comments.retain(|c| !is_noise(&c.body));
5194 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5195
5196 let mut found = pr(Checks::Green, &[], 0);
5197 found.review_comments.push(ReviewComment {
5198 author: "claude".to_owned(),
5199 path: None,
5200 line: None,
5201 body: CLAUDE_FINDING.to_owned(),
5202 });
5203 found.review_comments.retain(|c| !is_noise(&c.body));
5204 assert!(matches!(
5205 decide(&found, 0, 4, Duration::ZERO),
5206 Step::Fix { .. }
5207 ));
5208 }
5209
5210 #[test]
5211 fn the_policy_table_holds_for_every_combination_that_matters() {
5212 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5213 (
5214 "pending checks are waited for, even on the last round",
5215 pr(Checks::Pending, &[], 0),
5216 4,
5217 4,
5218 Duration::ZERO,
5219 Step::Wait,
5220 ),
5221 (
5222 "red checks are fixed",
5223 pr(Checks::Red, &["editorconfig"], 0),
5224 0,
5225 4,
5226 Duration::ZERO,
5227 Step::Fix {
5228 reason: "1 check(s) failing: editorconfig".to_owned(),
5229 },
5230 ),
5231 (
5232 "green with comments is fixed, not merged",
5233 pr(Checks::Green, &[], 2),
5234 1,
5235 4,
5236 Duration::ZERO,
5237 Step::Fix {
5238 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5239 .to_owned(),
5240 },
5241 ),
5242 (
5243 "green and clean merges",
5244 pr(Checks::Green, &[], 0),
5245 3,
5246 4,
5247 Duration::ZERO,
5248 Step::Merge,
5249 ),
5250 (
5251 "an unreadable rollup is waited on while the grace lasts",
5252 pr(Checks::Unknown, &[], 0),
5253 0,
5254 4,
5255 Duration::ZERO,
5256 Step::Wait,
5257 ),
5258 (
5259 "an unreadable rollup is never merged once the grace is spent",
5260 pr(Checks::Unknown, &[], 0),
5261 0,
5262 4,
5263 CHECKS_GRACE,
5264 Step::GiveUp {
5265 reason: "no check status is readable on the pull request after 3 minute(s); \
5266 refusing to merge on a guess"
5267 .to_owned(),
5268 },
5269 ),
5270 ];
5271 for (what, state, round, budget, waited, want) in cases {
5272 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5273 }
5274 }
5275
5276 #[test]
5277 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5278 let green = parse_pr(GREEN_OPEN).expect("parse");
5282 assert_eq!(green.blocking, Blocking::No);
5283 let red = parse_pr(RED_OPEN).expect("parse");
5284 assert_eq!(
5285 red.blocking,
5286 Blocking::No,
5287 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5288 );
5289 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5290 let quiet =
5292 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5293 assert_eq!(quiet.blocking, Blocking::Unsaid);
5294 }
5295
5296 #[test]
5297 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5298 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5304 nonblocking.blocking = Blocking::No;
5305 assert_eq!(
5306 decide(&nonblocking, 0, 4, Duration::ZERO),
5307 Step::Merge,
5308 "the forge says nothing is in the way, so nothing is"
5309 );
5310
5311 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5313 blocking.blocking = Blocking::Yes;
5314 assert!(matches!(
5315 decide(&blocking, 0, 4, Duration::ZERO),
5316 Step::Fix { .. }
5317 ));
5318
5319 let mut commented = pr(Checks::Red, &["coverage"], 1);
5322 commented.blocking = Blocking::No;
5323 assert!(matches!(
5324 decide(&commented, 0, 4, Duration::ZERO),
5325 Step::Fix { .. }
5326 ));
5327
5328 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5330 unsaid.blocking = Blocking::Unsaid;
5331 assert!(matches!(
5332 decide(&unsaid, 0, 4, Duration::ZERO),
5333 Step::Fix { .. }
5334 ));
5335 }
5336
5337 #[test]
5338 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5339 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5340 red.blocking = Blocking::No;
5341 assert_eq!(
5342 decide(&red, 0, 4, Duration::ZERO),
5343 Step::Merge,
5344 "announcing must not change the decision"
5345 );
5346 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5347 assert!(said.contains("yukimemi/magi"), "{said}");
5348 assert!(said.contains("#16"), "{said}");
5349 assert!(
5350 said.contains("https://github.com/yukimemi/magi/pull/16"),
5351 "{said}"
5352 );
5353 assert!(
5354 said.contains("test (windows-latest)") && said.contains("coverage"),
5355 "{said}"
5356 );
5357
5358 let green = pr(Checks::Green, &["stale"], 0);
5360 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5361 }
5362
5363 #[test]
5364 fn the_repo_label_comes_from_the_pull_request_url() {
5365 let p = Path::new("/tmp/checkout");
5366 assert_eq!(
5367 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5368 "yukimemi/magi"
5369 );
5370 assert_eq!(repo_label(p, "not a url"), "checkout");
5371 }
5372
5373 #[test]
5374 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5375 let mut conflicted = pr(Checks::Green, &[], 0);
5380 conflicted.blocking = Blocking::Conflict;
5381 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5382
5383 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5387 red.blocking = Blocking::Conflict;
5388 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5389
5390 let mut merged = pr(Checks::Red, &[], 0);
5392 merged.blocking = Blocking::Conflict;
5393 merged.state = PrLifecycle::Merged;
5394 assert_eq!(
5395 decide(&merged, 0, 4, Duration::ZERO),
5396 Step::Done { merged: true }
5397 );
5398 }
5399
5400 #[test]
5401 fn the_forge_verdict_is_read_off_merge_state_status() {
5402 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5405 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5406 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5407 }
5408 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5409 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5410 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5411 for quiet in ["", "UNKNOWN"] {
5414 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5415 assert!(Blocking::of(quiet).stops_a_merge());
5416 }
5417 }
5418
5419 #[test]
5420 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5421 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5422 let jj = "could not determine current branch: failed to run git: not on any branch";
5424
5425 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5426 .expect("the forge says merged, so it merged");
5427 assert!(landed.ok);
5428 assert!(
5429 landed.detail.contains("but the pull request is merged"),
5430 "the record must not read as a clean success: {}",
5431 landed.detail
5432 );
5433 assert!(
5434 landed.detail.contains("not on any branch"),
5435 "and it must keep what the command actually said: {}",
5436 landed.detail
5437 );
5438
5439 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5441 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5442 assert!(merged_after_all(&argv, jj, None).is_none());
5444 }
5445
5446 #[test]
5447 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5448 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5449 state.state = PrLifecycle::Closed;
5450 assert_eq!(
5451 decide(&state, 0, 4, Duration::ZERO),
5452 Step::Done { merged: false },
5453 "a human closing the pull request ends the loop, whatever CI says"
5454 );
5455 }
5456
5457 #[test]
5458 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5459 let red = decide(
5460 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5461 4,
5462 4,
5463 Duration::ZERO,
5464 );
5465 match red {
5466 Step::GiveUp { reason } => {
5467 assert!(reason.contains("editorconfig"), "reason: {reason}");
5468 assert!(reason.contains("test (macos)"), "reason: {reason}");
5469 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5470 }
5471 other => panic!("expected a give-up, got {other:?}"),
5472 }
5473
5474 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5475 match commented {
5476 Step::GiveUp { reason } => {
5477 assert!(reason.contains("unresolved"), "reason: {reason}");
5478 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5479 }
5480 other => panic!("expected a give-up, got {other:?}"),
5481 }
5482 }
5483
5484 #[test]
5485 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5486 let candidate_commit = "magi: candidate A (uncommitted work)";
5487 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5488 let argv = merge_argv(16, &subject);
5489
5490 assert!(argv.contains(&"--squash".to_owned()));
5491 assert!(argv.contains(&"--delete-branch".to_owned()));
5492 assert!(argv.contains(&"--subject".to_owned()));
5493 assert_eq!(
5494 argv.last().map(String::as_str),
5495 Some("add retries to the uploader"),
5496 "the subject must not be the candidate commit message"
5497 );
5498 assert_ne!(subject, candidate_commit);
5499 }
5500
5501 #[test]
5502 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5503 assert_eq!(
5504 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5505 "feat: a queue, an unattended loop, and a phone UI"
5506 );
5507 assert_eq!(
5508 merge_subject("", "# port the retry logic\n\ndetails"),
5509 "port the retry logic",
5510 "an empty title falls back to the task's first line, heading marks stripped"
5511 );
5512 }
5513
5514 #[test]
5515 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5516 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5517 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5518 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5519 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5520 assert_eq!(run_of(""), None);
5521 }
5522
5523 #[test]
5524 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5525 let mut out = Vec::new();
5526 push_if_outstanding(
5527 &mut out,
5528 ReviewComment {
5529 author: "yukimemi".to_owned(),
5530 path: None,
5531 line: None,
5532 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5533 },
5534 );
5535 assert!(out.is_empty());
5536 }
5537
5538 fn run_state() -> RunState {
5542 let mut state = RunState::new(
5543 std::path::PathBuf::from("/repo/magi"),
5544 "main".to_owned(),
5545 "abcdef1234".to_owned(),
5546 "add retries to the uploader".to_owned(),
5547 crate::config::Config::default(),
5548 );
5549 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5554 let nanos = std::time::SystemTime::now()
5555 .duration_since(std::time::UNIX_EPOCH)
5556 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5557 state.id = format!(
5558 "20261004-{nanos:06}-{:04x}",
5559 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5560 );
5561 state
5562 }
5563
5564 fn green_pr() -> PrState {
5565 PrState {
5566 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5567 number: 42,
5568 state: PrLifecycle::Open,
5569 checks: Checks::Green,
5570 blocking: Blocking::No,
5572 failing: Vec::new(),
5573 review_comments: vec![ReviewComment {
5574 author: "coderabbitai".to_owned(),
5575 path: Some("src/land.rs".to_owned()),
5576 line: Some(212),
5577 body: "this branch never checks the exit code".to_owned(),
5578 }],
5579 }
5580 }
5581
5582 #[test]
5583 fn github_facing_land_text_is_english_whatever_the_language() {
5584 let mut state = run_state();
5585 state.config.graph.language = "ja".to_owned();
5586 let comment = stop_comment(&state.id, "checks are still red");
5587 assert!(crate::github_text::check("", &comment).is_empty());
5588 assert!(comment.is_ascii(), "{comment}");
5589 assert!(comment.starts_with(MARKER));
5590
5591 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5592 let ja_at = p.find("Write all prose in ja").unwrap();
5593 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5594 assert!(ja_at < rule_at, "{p}");
5595 assert!(p.contains("stays in Japanese"), "{p}");
5596
5597 state.config.graph.language = "en".to_owned();
5598 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5599 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5600 assert!(!p.contains("does not apply"), "{p}");
5601 }
5602
5603 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5604
5605 fn panel() -> String {
5606 approval_panel(
5607 &run_state(),
5608 &green_pr(),
5609 NUMSTAT,
5610 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5611 &[
5612 "land: ask before merging".to_owned(),
5613 "land: colour the diff".to_owned(),
5614 ],
5615 "feat: merge approval from the phone",
5616 )
5617 }
5618
5619 #[test]
5620 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5621 let html = panel();
5622 for needle in [
5623 "42",
5624 "main",
5625 "src/land.rs",
5626 "src/web.rs",
5627 "assets/logo.png",
5628 "feat: merge approval from the phone",
5629 "land: ask before merging",
5630 "land: colour the diff",
5631 "coderabbitai",
5632 "this branch never checks the exit code",
5633 "green",
5634 ] {
5635 assert!(html.contains(needle), "the panel must state `{needle}`");
5636 }
5637 }
5638
5639 fn winning_candidate(summary: &str) -> Candidate {
5642 Candidate {
5643 index: 0,
5644 label: 'A',
5645 agent: "opus".to_owned(),
5646 branch: "magi/x/A".to_owned(),
5647 worktree: PathBuf::from("/wt/A"),
5648 summary: summary.to_owned(),
5649 stat: String::new(),
5650 files: 1,
5651 commits: 1,
5652 empty: false,
5653 failed: None,
5654 verified_noop: None,
5655 duration_ms: 0,
5656 folded: false,
5657 }
5658 }
5659
5660 fn uncontested_tally() -> Tally {
5661 Tally {
5662 first_choice: BTreeMap::from([('A', 1)]),
5663 borda: BTreeMap::new(),
5664 winner: 'A',
5665 rankings: 1,
5666 unanimous_initial: true,
5667 deliberated: false,
5668 changed_votes: 0,
5669 unanimous_final: true,
5670 tie_break: None,
5671 judges: 1,
5672 present: 1,
5673 quorum: 1,
5674 met_quorum: true,
5675 uncontested: None,
5676 }
5677 }
5678
5679 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5680 ReviewRecord {
5681 attempts: 0,
5682 reviewer,
5683 agent: agent.to_owned(),
5684 summary: summary.to_owned(),
5685 findings: Vec::new(),
5686 vote: None,
5687 failed: None,
5688 duration_ms: 0,
5689 }
5690 }
5691
5692 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5693 let answered = reviews.len();
5694 ReviewRound {
5695 round,
5696 head: "abc1234".to_owned(),
5697 verified_head: None,
5698 verified_at: None,
5699 reviews,
5700 e2e: Vec::new(),
5701 verify_retried: false,
5702 e2e_deferred: false,
5703 e2e_defer_reason: None,
5704 fix: None,
5705 blocking: 0,
5706 answered,
5707 expected: answered,
5708 clean: true,
5709 progressed: false,
5710 vote_split: false,
5711 reconsideration: Vec::new(),
5712 verdict: None,
5713 }
5714 }
5715
5716 #[test]
5717 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5718 let en = panel();
5719 assert!(en.contains("Task"), "{en}");
5720 assert!(en.contains("add retries to the uploader"), "{en}");
5721
5722 let mut state = run_state();
5723 state.config.graph.language = "ja".to_owned();
5724 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5725 assert!(ja.contains("タスク"), "{ja}");
5726 assert!(
5727 ja.contains("add retries to the uploader"),
5728 "the task itself is not translated: {ja}"
5729 );
5730 }
5731
5732 #[test]
5733 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5734 let html = panel();
5738 assert!(!html.contains("What changed"), "{html}");
5739 assert!(!html.contains("Review verdict"), "{html}");
5740 }
5741
5742 #[test]
5743 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5744 let mut state = run_state();
5745 state.candidates = vec![winning_candidate("")];
5746 state.tally = Some(uncontested_tally());
5747 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5748 assert!(
5749 !html.contains("What changed"),
5750 "an empty summary must not render an empty box: {html}"
5751 );
5752 }
5753
5754 #[test]
5755 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5756 let mut state = run_state();
5757 state.candidates = vec![winning_candidate(
5758 "Added a retry loop around the uploader PUT call.",
5759 )];
5760 state.tally = Some(uncontested_tally());
5761 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5762 assert!(en.contains("What changed"), "{en}");
5763 assert!(
5764 en.contains("Added a retry loop around the uploader PUT call."),
5765 "{en}"
5766 );
5767
5768 state.config.graph.language = "ja".to_owned();
5769 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5770 assert!(ja.contains("変更内容"), "{ja}");
5771 assert!(
5772 ja.contains("Added a retry loop around the uploader PUT call."),
5773 "{ja}"
5774 );
5775 }
5776
5777 #[test]
5778 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5779 let mut state = run_state();
5780 state.reviews = vec![
5781 review_round(
5782 1,
5783 vec![review_record(1, "alpha", "found a race, sent back")],
5784 ),
5785 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5786 ];
5787 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5788 assert!(en.contains("Review verdict"), "{en}");
5789 assert!(en.contains("race is fixed, clean"), "{en}");
5790 assert!(
5791 !en.contains("found a race, sent back"),
5792 "only the round that actually cleared the merge should show: {en}"
5793 );
5794
5795 state.config.graph.language = "ja".to_owned();
5796 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5797 assert!(ja.contains("レビューの結論"), "{ja}");
5798 assert!(ja.contains("レビュアー"), "{ja}");
5799 assert!(ja.contains("race is fixed, clean"), "{ja}");
5800 }
5801
5802 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5808 ReviewRecord {
5809 attempts: 0,
5810 reviewer,
5811 agent: agent.to_owned(),
5812 summary: String::new(),
5813 findings: Vec::new(),
5814 vote: None,
5815 failed: Some(reason.to_owned()),
5816 duration_ms: 0,
5817 }
5818 }
5819
5820 #[test]
5821 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5822 let mut state = run_state();
5823 state.reviews = vec![review_round(
5824 1,
5825 vec![
5826 review_record(1, "alpha", "clean, nothing to add"),
5827 unanswered_review_record(2, "beta", "timed out"),
5828 ],
5829 )];
5830 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5831 assert!(en.contains("clean, nothing to add"), "{en}");
5832 assert!(
5833 en.contains("produced no answer: timed out"),
5834 "a seat that never answered must say so, not render a blank box: {en}"
5835 );
5836 assert!(
5837 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5838 "no reviewer box may be left empty: {en}"
5839 );
5840
5841 state.config.graph.language = "ja".to_owned();
5842 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5843 assert!(ja.contains("回答なし: timed out"), "{ja}");
5844 }
5845
5846 #[test]
5847 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5848 let html = panel();
5849 assert!(!html.contains("<script"), "no script survives the csp");
5850 assert!(!html.contains("<form"), "form-action is 'none'");
5851 let pr = green_pr();
5852 assert_eq!(
5853 html.matches("http").count(),
5854 html.matches(pr.url.as_str()).count(),
5855 "the only http url in the panel is the pull request's own link"
5856 );
5857 assert!(
5858 html.contains("target=\"_blank\""),
5859 "the PR link must open in a new tab so it escapes the iframe"
5860 );
5861 assert!(
5862 html.contains("rel=\"noopener noreferrer\""),
5863 "external links opening in new tabs must carry noopener noreferrer"
5864 );
5865 }
5866
5867 #[test]
5868 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5869 let html = panel();
5870 assert!(
5871 html.contains(">+</span>"),
5872 "an added line carries a `+` in the gutter, not only a background"
5873 );
5874 assert!(
5875 html.contains(">-</span>"),
5876 "a removed line carries a `-` in the gutter, not only a background"
5877 );
5878 assert!(
5879 html.contains(">new line</span>"),
5880 "the marker is moved to the gutter, so the body is printed once without it"
5881 );
5882 }
5883
5884 #[test]
5885 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5886 let total = DIFF_MAX_LINES + 100;
5887 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5888 let html = approval_panel(
5889 &run_state(),
5890 &green_pr(),
5891 NUMSTAT,
5892 &diff,
5893 &[],
5894 "feat: something long",
5895 );
5896 assert!(
5897 html.contains(&format!("100 of {total} diff lines omitted")),
5898 "the note must say exactly how much was cut"
5899 );
5900 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5901 assert!(
5902 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5903 "nothing past the threshold is rendered"
5904 );
5905 assert!(
5906 html.contains("/repo/magi"),
5907 "the note says where the rest is"
5908 );
5909 }
5910
5911 #[test]
5912 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5913 let html = approval_panel(
5914 &run_state(),
5915 &green_pr(),
5916 "1\t2\tsrc/<b>&\"x\"'.rs",
5917 "",
5918 &[],
5919 "subject",
5920 );
5921 assert!(html.contains("src/<b>&"x"'.rs"));
5922 assert!(
5923 !html.contains("<b>"),
5924 "an agent-influenced path must never become markup"
5925 );
5926 }
5927
5928 #[tokio::test]
5929 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5930 let a = std::path::PathBuf::from("/repo/a");
5931 let b = std::path::PathBuf::from("/repo/b");
5932
5933 let held = repo_merge_lock(&a).lock_owned().await;
5934
5935 assert!(
5938 repo_merge_lock(&a).try_lock().is_err(),
5939 "a second merge into the same repository must not proceed concurrently"
5940 );
5941
5942 assert!(
5946 repo_merge_lock(&b).try_lock().is_ok(),
5947 "a different repository's merge lock must be independent"
5948 );
5949
5950 drop(held);
5951 assert!(
5952 repo_merge_lock(&a).try_lock().is_ok(),
5953 "the lock is released once the holder is done"
5954 );
5955 }
5956
5957 #[test]
5958 fn only_the_merge_choice_merges_and_silence_holds() {
5959 let table = [
5960 (None, Approval::Hold),
5961 (Some("merge"), Approval::Merge),
5962 (Some(" merge\n"), Approval::Merge),
5963 (Some("hold"), Approval::Hold),
5964 (Some(""), Approval::Hold),
5965 (Some("yes"), Approval::Hold),
5966 ];
5967 for (answer, want) in table {
5968 assert_eq!(
5969 approval(answer),
5970 want,
5971 "answer {answer:?} must resolve to {want:?}"
5972 );
5973 }
5974 }
5975
5976 #[tokio::test]
5977 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5978 let mut state = landing_state();
5979 state.config.graph.land_approval = true;
5980 let pr = green_pr();
5981
5982 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5983 .await
5984 .unwrap();
5985 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5986 assert!(
5987 !state.parked,
5988 "approval_gate itself never sets `parked`; only its caller does"
5989 );
5990
5991 let store = ask::Questions::open();
5992 let filed: Vec<_> = store
5993 .list()
5994 .into_iter()
5995 .filter(|q| q.run == state.id)
5996 .collect();
5997 assert_eq!(filed.len(), 1, "exactly one question is filed");
5998 assert_eq!(filed[0].node, APPROVAL_NODE);
5999 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
6000 assert!(filed[0].status.open());
6001
6002 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6006 .await
6007 .unwrap();
6008 assert_eq!(again, ApprovalGate::Pending);
6009 let still_one = store
6010 .list()
6011 .into_iter()
6012 .filter(|q| q.run == state.id)
6013 .count();
6014 assert_eq!(
6015 still_one, 1,
6016 "asking twice must not double-file the question"
6017 );
6018 }
6019
6020 #[tokio::test]
6021 async fn approving_the_existing_question_is_read_back_as_approved() {
6022 crate::run::pin_test_home();
6023 let mut state = run_state();
6024 state.config.graph.land_approval = true;
6025 let pr = green_pr();
6026 assert_eq!(
6027 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6028 .await
6029 .unwrap(),
6030 ApprovalGate::Pending
6031 );
6032
6033 let store = ask::Questions::open();
6034 let mut q = store
6035 .list()
6036 .into_iter()
6037 .find(|q| q.run == state.id)
6038 .expect("filed above");
6039 let talks = crate::talk::Talks::open();
6040 let cfg = crate::config::Config {
6041 agents: vec![crate::config::AgentSpec {
6042 id: "mock".into(),
6043 kind: crate::config::AgentKind::Command,
6044 model: None,
6045 command: vec!["true".into()],
6046 extra_args: Vec::new(),
6047 env: Default::default(),
6048 prompt_delivery: None,
6049 }],
6050 ..crate::config::Config::default()
6051 };
6052 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6053 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6054 crate::talk::drain(&mut talk, &talks).unwrap();
6055 assert_eq!(
6056 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6057 .await
6058 .unwrap(),
6059 ApprovalGate::Pending,
6060 "consultation cannot approve or hold the merge"
6061 );
6062 q = store.get(&q.id).unwrap();
6063 assert!(q.answer.is_none());
6064 talk.turns.push(crate::talk::Turn {
6065 breaks: Some(Vec::new()),
6066 messages: Vec::new(),
6067 posted_by: None,
6068 who: crate::talk::Who::Operator,
6069 body: "Merge this pull request now".into(),
6070 at: Timestamp::now(),
6071 attachments: Vec::new(),
6072 usage: None,
6073 });
6074 talks.put(&mut talk).unwrap();
6075 crate::consult::validate_answer(
6076 &q,
6077 &talks,
6078 &talk.id,
6079 crate::queue::CHAT_NODE,
6080 APPROVE,
6081 Some("Merge this pull request now"),
6082 )
6083 .unwrap();
6084 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6085 store.put(&mut q).unwrap();
6086
6087 assert_eq!(
6088 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6089 .await
6090 .unwrap(),
6091 ApprovalGate::Approved
6092 );
6093 }
6094
6095 #[tokio::test]
6096 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6097 crate::run::pin_test_home();
6098 let store = ask::Questions::open();
6099
6100 let mut held_state = run_state();
6101 held_state.config.graph.land_approval = true;
6102 let pr = green_pr();
6103 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6104 .await
6105 .unwrap();
6106 let mut q = store
6107 .list()
6108 .into_iter()
6109 .find(|q| q.run == held_state.id)
6110 .expect("filed above");
6111 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6112 store.put(&mut q).unwrap();
6113 assert_eq!(
6114 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6115 .await
6116 .unwrap(),
6117 ApprovalGate::Held
6118 );
6119
6120 let mut abandoned_state = run_state();
6121 abandoned_state.config.graph.land_approval = true;
6122 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6123 .await
6124 .unwrap();
6125 let mut q = store
6126 .list()
6127 .into_iter()
6128 .find(|q| q.run == abandoned_state.id)
6129 .expect("filed above");
6130 q.abandon("no answer within the timeout");
6131 store.put(&mut q).unwrap();
6132 assert_eq!(
6133 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6134 .await
6135 .unwrap(),
6136 ApprovalGate::Held,
6137 "silence must never merge"
6138 );
6139 }
6140
6141 fn contested() -> ContestedHandoff {
6142 let finding = |id: &str, n: u32| crate::verdict::Finding {
6143 id: id.to_owned(),
6144 severity: crate::verdict::Severity::Major,
6145 file: Some("src/a.rs".to_owned()),
6146 line: Some(n),
6147 title: format!("problem {id}"),
6148 detail: String::new(),
6149 };
6150 ContestedHandoff {
6151 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6152 rejecters: vec![(1, "alpha".to_owned())],
6153 }
6154 }
6155
6156 #[test]
6157 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6158 let mut state = run_state();
6159 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6160 state.contested_handoff = Some(contested());
6161 assert!(contested_to_ask(&state).is_some());
6162 state.config.graph.hold_contested_merge = false;
6163 assert!(
6164 contested_to_ask(&state).is_none(),
6165 "the switch restores today"
6166 );
6167 }
6168
6169 #[test]
6170 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6171 let q = ask::Question::new(
6172 "run-1".to_owned(),
6173 APPROVAL_NODE.to_owned(),
6174 "land".to_owned(),
6175 "Merge?".to_owned(),
6176 String::new(),
6177 vec![APPROVE.to_owned(), HOLD.to_owned()],
6178 );
6179 let none = deputy_brief(&q, None);
6180 assert!(none.contains("could not be read"), "{none}");
6181 assert!(none.contains("Silence is a hold"), "{none}");
6182
6183 let mut state = run_state();
6184 state.pr = Some(crate::run::PrRecord {
6185 url: "https://example.test/pull/7".to_owned(),
6186 number: 7,
6187 state: "open".to_owned(),
6188 checks: "green".to_owned(),
6189 round: 0,
6190 rounds: 3,
6191 red_at_merge: Vec::new(),
6192 });
6193 state.contested_handoff = Some(contested());
6194 let b = deputy_brief(&q, Some(&state));
6195 assert!(b.contains("https://example.test/pull/7"), "{b}");
6196 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6197 assert!(b.contains("#1"), "the rejecting seat: {b}");
6198 state.contested_handoff = None;
6199 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6200 }
6201
6202 #[test]
6203 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6204 for lang in ["en", "ja"] {
6205 let mut cfg = crate::config::Config::default();
6206 cfg.graph.language = lang.to_owned();
6207 let w = words(&cfg.graph.language);
6208 let text = w.approval_detail(
6209 "https://github.com/yukimemi/magi/pull/42",
6210 "main",
6211 "feat: x",
6212 Some(&contested()),
6213 );
6214 assert!(text.contains("pull/42"), "{text}");
6215 assert!(
6216 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6217 "{text}"
6218 );
6219 assert!(text.contains("R3-1-5"), "{text}");
6220 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6221 assert!(text.contains("2"), "the rest are counted: {text}");
6222 assert!(text.contains("#1 (alpha)"), "{text}");
6223 }
6224 let plain = words("en").approval_detail("u", "main", "s", None);
6225 assert!(!plain.contains("reject"), "{plain}");
6226 }
6227
6228 #[tokio::test]
6229 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6230 crate::run::pin_test_home();
6231 let mut state = run_state();
6232 state.config.graph.land_approval = false;
6233 state.contested_handoff = Some(contested());
6234 let pr = green_pr();
6235 let c = contested_to_ask(&state);
6236 assert_eq!(
6237 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6238 .await
6239 .unwrap(),
6240 ApprovalGate::Pending,
6241 "silence is a hold"
6242 );
6243 let store = ask::Questions::open();
6244 let filed: Vec<_> = store
6245 .list()
6246 .into_iter()
6247 .filter(|q| q.run == state.id)
6248 .collect();
6249 assert_eq!(filed.len(), 1);
6250 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6251
6252 assert_eq!(
6253 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6254 .await
6255 .unwrap(),
6256 ApprovalGate::Pending
6257 );
6258 let mut q = store
6259 .list()
6260 .into_iter()
6261 .find(|q| q.run == state.id)
6262 .unwrap();
6263 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6264 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6265 store.put(&mut q).unwrap();
6266 assert_eq!(
6267 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6268 .await
6269 .unwrap(),
6270 ApprovalGate::Approved
6271 );
6272 }
6273
6274 #[test]
6275 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6276 let rows = parse_numstat(NUMSTAT);
6277 assert_eq!(
6278 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6279 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6280 );
6281 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6282 }
6283 #[test]
6284 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6285 let mut state = run_state();
6289 state.config.graph.language = "ja".to_owned();
6290 let pr = green_pr();
6291 let commits = ["c1".to_owned()];
6292
6293 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6294 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6295 assert!(ja.contains("squash されるコミット"), "{ja}");
6296 assert!(ja.contains("レビューコメント"), "{ja}");
6297 assert!(ja.contains("差分"), "{ja}");
6298 assert!(
6299 !ja.contains("Commits being squashed"),
6300 "no English left over"
6301 );
6302
6303 let w = words("ja");
6304 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6305 assert!(
6306 w.approval_detail("http://x/1", "main", "feat: x", None)
6307 .contains("パネル")
6308 );
6309
6310 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6312 assert!(ja.contains("feat: x"), "nor is the merge subject");
6313
6314 state.config.graph.language = "en".to_owned();
6317 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6318 assert!(en.contains("Commits being squashed"), "{en}");
6319 assert_eq!(words("Klingon").html_lang, "en");
6320 }
6321
6322 #[test]
6326 fn pick_open_pr_classifies_by_count_and_base() {
6327 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6328 assert_eq!(
6329 pick_open_pr(one, "main").unwrap(),
6330 OpenPr::One {
6331 url: "https://x/pull/58".into(),
6332 title: "t".into()
6333 }
6334 );
6335 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6336 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6337 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6338 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6339 assert_eq!(
6340 pick_open_pr(two, "main").unwrap(),
6341 OpenPr::Many(vec!["u1".into(), "u2".into()])
6342 );
6343 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6345 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6346 assert!(pick_open_pr("not json", "main").is_err());
6347 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6349 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6350 }
6351
6352 #[test]
6353 fn pick_merged_pr_picks_the_unique_match() {
6354 let json = r#"[
6355 {"url": "https://github.com/o/r/pull/42", "number": 42,
6356 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6357 ]"#;
6358 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6359 let found = pick_merged_pr(json, "main", created_at)
6360 .expect("valid json")
6361 .expect("one unambiguous match");
6362 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6363 assert_eq!(found.number, 42);
6364 }
6365
6366 #[test]
6370 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6371 let json = r#"[
6372 {"url": "https://github.com/o/r/pull/42", "number": 42,
6373 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6374 {"url": "https://github.com/o/r/pull/43", "number": 43,
6375 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6376 ]"#;
6377 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6378 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6379 }
6380
6381 #[test]
6385 fn pick_merged_pr_ignores_a_different_base_branch() {
6386 let json = r#"[
6387 {"url": "https://github.com/o/r/pull/42", "number": 42,
6388 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6389 ]"#;
6390 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6391 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6392 }
6393
6394 #[test]
6397 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6398 let json = r#"[
6399 {"url": "https://github.com/o/r/pull/42", "number": 42,
6400 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6401 ]"#;
6402 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6403 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6404 }
6405
6406 #[test]
6407 fn slug_of_pr_url_reads_host_owner_and_repo() {
6408 assert_eq!(
6409 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6410 Some("github.com/yukimemi/shun")
6411 );
6412 }
6413
6414 #[test]
6415 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6416 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6417 assert_eq!(slug_of_pr_url("not a url at all"), None);
6418 assert_eq!(slug_of_pr_url("https://github.com"), None);
6419 }
6420
6421 #[test]
6422 fn slug_of_repo_url_reads_host_owner_and_repo() {
6423 assert_eq!(
6424 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6425 Some("github.com/yukimemi/magi")
6426 );
6427 assert_eq!(slug_of_repo_url("https://github.com"), None);
6428 }
6429
6430 #[test]
6431 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6432 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6433 .expect("same repo, different case");
6434 }
6435
6436 #[test]
6441 fn ensure_same_repo_refuses_a_different_repo() {
6442 let err =
6443 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6444 let msg = format!("{err:#}");
6445 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6446 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6447 }
6448
6449 #[test]
6454 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6455 let err = ensure_same_repo(
6456 "github.com/yukimemi/magi",
6457 "github.example.com/yukimemi/magi",
6458 )
6459 .unwrap_err();
6460 let msg = format!("{err:#}");
6461 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6462 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6463 }
6464
6465 #[tokio::test]
6470 async fn find_external_merge_returns_none_without_a_winner() {
6471 let state = RunState::new(
6472 PathBuf::from("/no/such/repo"),
6473 "main".to_owned(),
6474 "0000000000000000000000000000000000000000".to_owned(),
6475 "irrelevant".to_owned(),
6476 crate::config::Config::default(),
6477 );
6478 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6479 }
6480
6481 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6482 let mut run = RunState::new(
6483 PathBuf::from(repo),
6484 "main".to_owned(),
6485 "abcdef1234".to_owned(),
6486 "x".to_owned(),
6487 crate::config::Config::default(),
6488 );
6489 run.id = id.to_owned();
6490 run.status = status;
6491 run.pr = Some(crate::run::PrRecord {
6492 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6493 url: url.to_owned(),
6494 state: state.to_owned(),
6495 checks: "red".to_owned(),
6496 round: 0,
6497 rounds: 2,
6498 red_at_merge: Vec::new(),
6499 });
6500 run.save_under(home).unwrap();
6501 }
6502
6503 fn recorded(home: &Path, id: &str) -> String {
6504 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6505 serde_json::from_str::<RunState>(&body)
6506 .unwrap()
6507 .pr
6508 .unwrap()
6509 .state
6510 }
6511
6512 const PR: &str = "https://github.com/o/r/pull/7";
6513
6514 #[test]
6515 fn write_through_updates_predecessors_and_siblings_only() {
6516 let tmp = tempfile::tempdir().unwrap();
6517 let h = tmp.path();
6518 pr_run(
6519 h,
6520 "20261004-100000-aaaa",
6521 "/repo/r",
6522 RunStatus::Superseded,
6523 PR,
6524 "open",
6525 );
6526 pr_run(
6527 h,
6528 "20261004-100100-bbbb",
6529 "/repo/r",
6530 RunStatus::Blocked,
6531 PR,
6532 "open",
6533 );
6534 pr_run(
6536 h,
6537 "20261004-100200-cccc",
6538 "/repo/r",
6539 RunStatus::Landing,
6540 PR,
6541 "open",
6542 );
6543 pr_run(
6545 h,
6546 "20261004-100300-dddd",
6547 "/repo/other",
6548 RunStatus::Blocked,
6549 "https://github.com/o/other/pull/7",
6550 "open",
6551 );
6552 pr_run(
6554 h,
6555 "20261004-100400-eeee",
6556 "/repo/r",
6557 RunStatus::Blocked,
6558 "https://github.com/o/r/pull/8",
6559 "open",
6560 );
6561 pr_run(
6562 h,
6563 "20261004-100500-ffff",
6564 "/repo/r",
6565 RunStatus::Merged,
6566 PR,
6567 "open",
6568 );
6569 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6570
6571 assert_eq!(
6572 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6573 2
6574 );
6575 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6576 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6577 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6578 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6579 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6580 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6582 assert_eq!(
6584 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6585 0
6586 );
6587 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6588 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6589 }
6590
6591 #[test]
6592 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6593 let tmp = tempfile::tempdir().unwrap();
6594 let h = tmp.path();
6595 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6596 pr_run(
6597 h,
6598 "20261004-100000-aaaa",
6599 "/repo/r",
6600 RunStatus::Superseded,
6601 &url(1),
6602 "open",
6603 );
6604 pr_run(
6605 h,
6606 "20261004-100100-bbbb",
6607 "/repo/r",
6608 RunStatus::Blocked,
6609 &url(2),
6610 "open",
6611 );
6612 pr_run(
6613 h,
6614 "20261004-100200-cccc",
6615 "/repo/r",
6616 RunStatus::Ready,
6617 &url(3),
6618 "open",
6619 );
6620 pr_run(
6621 h,
6622 "20261004-100300-dddd",
6623 "/repo/r",
6624 RunStatus::Ready,
6625 &url(4),
6626 "open",
6627 );
6628 pr_run(
6629 h,
6630 "20261004-100400-eeee",
6631 "/repo/r",
6632 RunStatus::Implementing,
6633 &url(1),
6634 "open",
6635 );
6636 assert_eq!(stale_open_prs(h).len(), 4);
6637
6638 let mut known = BTreeMap::new();
6639 known.insert(url(1), PrLifecycle::Merged);
6640 known.insert(url(2), PrLifecycle::Closed);
6641 known.insert(url(3), PrLifecycle::Open);
6642 assert_eq!(apply_pr_states(h, &known), 2);
6644 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6645 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6646 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6647 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6648 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6649 assert_eq!(apply_pr_states(h, &known), 0);
6650 }
6651
6652 use std::collections::VecDeque;
6655 use std::sync::Mutex;
6656
6657 struct Scripted {
6660 views: Mutex<VecDeque<Seen>>,
6661 merges: Mutex<VecDeque<(bool, String)>>,
6662 fix: Mutex<Option<Fixed>>,
6663 log: Mutex<Vec<&'static str>>,
6664 argvs: Mutex<Vec<Vec<String>>>,
6665 required: Mutex<Option<BTreeSet<String>>>,
6666 merged: Mutex<bool>,
6669 queued: Mutex<bool>,
6670 unreadable_after_merge: Mutex<bool>,
6672 reruns: Mutex<Vec<String>>,
6674 rerun_fails: Mutex<bool>,
6675 clock: Mutex<i64>,
6677 }
6678
6679 impl Scripted {
6680 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6681 Self {
6682 views: Mutex::new(views.into()),
6683 merges: Mutex::new(
6684 merges
6685 .into_iter()
6686 .map(|(ok, m)| (ok, m.to_owned()))
6687 .collect(),
6688 ),
6689 fix: Mutex::new(None),
6690 log: Mutex::new(Vec::new()),
6691 argvs: Mutex::new(Vec::new()),
6692 required: Mutex::new(None),
6693 merged: Mutex::new(false),
6694 queued: Mutex::new(false),
6695 unreadable_after_merge: Mutex::new(false),
6696 reruns: Mutex::new(Vec::new()),
6697 rerun_fails: Mutex::new(false),
6698 clock: Mutex::new(1_000),
6699 }
6700 }
6701 fn argvs(&self) -> Vec<Vec<String>> {
6702 self.argvs.lock().unwrap().clone()
6703 }
6704 fn calls(&self) -> Vec<&'static str> {
6705 self.log.lock().unwrap().clone()
6706 }
6707 }
6708
6709 impl Forge for Scripted {
6710 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6711 self.log.lock().unwrap().push("view");
6712 if *self.unreadable_after_merge.lock().unwrap()
6713 && !self.argvs.lock().unwrap().is_empty()
6714 {
6715 anyhow::bail!("forge unreachable");
6716 }
6717 let mut v = self.views.lock().unwrap();
6718 let mut seen = if v.len() > 1 {
6719 v.pop_front().unwrap()
6720 } else {
6721 v[0].clone()
6722 };
6723 if *self.merged.lock().unwrap() {
6724 seen.pr.state = PrLifecycle::Merged;
6725 }
6726 Ok(seen)
6727 }
6728 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6729 self.log.lock().unwrap().push("merge");
6730 self.argvs.lock().unwrap().push(argv.to_vec());
6731 let out = self
6732 .merges
6733 .lock()
6734 .unwrap()
6735 .pop_front()
6736 .expect("unscripted merge");
6737 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6738 {
6739 *self.merged.lock().unwrap() = true;
6740 }
6741 Ok(out)
6742 }
6743 async fn poll(&self) {
6744 self.log.lock().unwrap().push("poll");
6745 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
6746 }
6747 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
6748 self.log.lock().unwrap().push("rerun");
6749 self.reruns.lock().unwrap().push(run.to_owned());
6750 if *self.rerun_fails.lock().unwrap() {
6751 anyhow::bail!("gh run rerun: no");
6752 }
6753 Ok(())
6754 }
6755 fn now(&self) -> i64 {
6756 *self.clock.lock().unwrap()
6757 }
6758 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6759 self.required.lock().unwrap().clone()
6760 }
6761 async fn fix(
6762 &self,
6763 _state: &mut RunState,
6764 _pr: &PrState,
6765 _round: usize,
6766 _budget: usize,
6767 _reason: &str,
6768 _logs: &str,
6769 ) -> Result<Fixed> {
6770 self.log.lock().unwrap().push("fix");
6771 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6772 }
6773 }
6774
6775 const REFUSED: &str =
6776 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6777
6778 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6779 let mut pr = green_pr();
6780 pr.checks = checks;
6781 pr.blocking = Blocking::of(merge_state);
6782 if !comments {
6783 pr.review_comments.clear();
6784 }
6785 Seen {
6786 pr,
6787 title: "feat: x".to_owned(),
6788 failing_urls: Vec::new(),
6789 head: head.to_owned(),
6790 rollup_head: head.to_owned(),
6791 merge_state: merge_state.to_owned(),
6792 contexts: Vec::new(),
6793 base: "main".to_owned(),
6794 }
6795 }
6796
6797 fn landing_state() -> RunState {
6798 crate::run::pin_test_home();
6799 let mut state = run_state();
6800 state.config.graph.land_approval = false;
6801 state
6802 }
6803
6804 #[test]
6805 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6806 assert!(!awaiting_new_head(None, "aaa"));
6807 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6808 assert!(awaiting_new_head(Some("abc123"), "def456"));
6809 assert!(awaiting_new_head(Some("abc123"), ""));
6810 }
6811
6812 #[test]
6813 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6814 let open = |c, m: &str| seen("a", c, m, false);
6815 let table = [
6816 (None, false, Refused::Pending),
6817 (
6818 Some(open(Checks::Pending, "BLOCKED")),
6819 false,
6820 Refused::Pending,
6821 ),
6822 (
6823 Some(open(Checks::Unknown, "BLOCKED")),
6824 false,
6825 Refused::Pending,
6826 ),
6827 (
6828 Some(open(Checks::Green, "UNKNOWN")),
6829 false,
6830 Refused::Pending,
6831 ),
6832 (Some(open(Checks::Green, "")), false, Refused::Pending),
6833 (
6834 Some(open(Checks::Green, "BLOCKED")),
6835 false,
6836 Refused::Recheck,
6837 ),
6838 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6839 ];
6840 for (after, rechecked, want) in table {
6841 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6842 }
6843 let mut closed = open(Checks::Green, "CLEAN");
6844 closed.pr.state = PrLifecycle::Closed;
6845 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6846 }
6847
6848 #[tokio::test]
6849 async fn a_normal_landing_merges_on_the_first_look() {
6850 let mut state = landing_state();
6851 let forge = Scripted::new(
6852 vec![seen("a", Checks::Green, "CLEAN", false)],
6853 vec![(true, "")],
6854 );
6855 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6856 .await
6857 .unwrap();
6858 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6860 assert_eq!(state.status, RunStatus::Merged);
6861 }
6862
6863 #[tokio::test]
6864 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6865 let mut state = landing_state();
6866 let forge = Scripted::new(
6867 vec![seen("a", Checks::Green, "CLEAN", false)],
6868 std::iter::repeat_n((true, ""), 100).collect(),
6869 );
6870 *forge.queued.lock().unwrap() = true;
6871 let task = async {
6872 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6873 .await
6874 .unwrap();
6875 };
6876 task.await;
6879 assert_ne!(state.status, RunStatus::Merged);
6880 }
6881
6882 #[tokio::test]
6883 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6884 let mut state = landing_state();
6885 let forge = Scripted::new(
6886 vec![seen("a", Checks::Green, "CLEAN", false)],
6887 std::iter::repeat_n((true, ""), 100).collect(),
6888 );
6889 *forge.unreadable_after_merge.lock().unwrap() = true;
6890 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6891 .await
6892 .ok();
6893 assert_ne!(state.status, RunStatus::Merged);
6894 }
6895
6896 #[tokio::test]
6897 async fn a_task_public_head_is_awaited_on_land_entry() {
6898 let mut state = landing_state();
6899 state.pr_head = Some("magi/task-abcd-1234".into());
6900 state.pr_pushed_head = Some("new".into());
6901 let forge = Scripted::new(
6902 vec![
6903 seen("old", Checks::Green, "CLEAN", false),
6904 seen("new", Checks::Green, "CLEAN", false),
6905 ],
6906 vec![(true, "")],
6907 );
6908 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6909 .await
6910 .unwrap();
6911 assert_eq!(
6912 forge.calls(),
6913 ["view", "poll", "view", "view", "merge", "view"]
6914 );
6915 assert_eq!(state.status, RunStatus::Merged);
6916 }
6917
6918 #[tokio::test]
6919 async fn a_superseded_public_head_owner_cannot_reenter_land() {
6920 let mut state = landing_state();
6921 state.pr_taken_over_by = Some("next".into());
6922 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
6923 assert!(
6924 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6925 .await
6926 .is_err()
6927 );
6928 assert!(forge.calls().is_empty());
6929 }
6930
6931 #[tokio::test]
6932 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6933 let mut state = landing_state();
6934 let forge = Scripted::new(
6935 vec![
6936 seen("old", Checks::Green, "CLEAN", true),
6937 seen("old", Checks::Green, "CLEAN", true),
6939 seen("new", Checks::Pending, "BLOCKED", true),
6940 seen("new", Checks::Green, "CLEAN", true),
6941 ],
6942 vec![(true, "")],
6943 );
6944 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6945 head: "NEW".to_owned(),
6946 });
6947 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6948 .await
6949 .unwrap();
6950 assert_eq!(
6951 forge.calls(),
6952 [
6953 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6954 "view"
6955 ]
6956 );
6957 assert_eq!(state.status, RunStatus::Merged);
6958 }
6959
6960 #[tokio::test]
6961 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6962 let mut state = landing_state();
6963 let forge = Scripted::new(
6964 vec![
6965 seen("old", Checks::Green, "CLEAN", true),
6966 seen("someone-elses", Checks::Green, "CLEAN", true),
6967 ],
6968 vec![],
6969 );
6970 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6971 head: "mine".to_owned(),
6972 });
6973 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6974 .await
6975 .unwrap();
6976 assert!(!forge.calls().contains(&"merge"));
6977 let why = state.merge.as_ref().unwrap().detail.clone();
6978 assert!(
6979 why.contains("mine") && why.contains("someone-elses"),
6980 "{why}"
6981 );
6982 assert_eq!(state.status, RunStatus::Blocked);
6983 }
6984
6985 #[tokio::test]
6986 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6987 let mut state = landing_state();
6988 let forge = Scripted::new(
6989 vec![
6990 seen("a", Checks::Green, "CLEAN", false),
6991 seen("a", Checks::Green, "CLEAN", false),
6992 seen("a", Checks::Pending, "BLOCKED", false),
6993 seen("a", Checks::Pending, "BLOCKED", false),
6994 seen("a", Checks::Green, "CLEAN", false),
6995 ],
6996 vec![(false, REFUSED), (true, "")],
6997 );
6998 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6999 .await
7000 .unwrap();
7001 assert_eq!(
7002 forge.calls(),
7003 [
7004 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7005 "view"
7006 ]
7007 );
7008 assert_eq!(state.status, RunStatus::Merged);
7009 }
7010
7011 #[tokio::test]
7012 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7013 let mut state = landing_state();
7014 let forge = Scripted::new(
7015 vec![
7016 seen("a", Checks::Green, "CLEAN", false),
7017 seen("a", Checks::Green, "BLOCKED", false),
7018 ],
7019 vec![(false, REFUSED), (false, REFUSED)],
7020 );
7021 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7022 .await
7023 .unwrap();
7024 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7026 let why = state.merge.as_ref().unwrap().detail.clone();
7027 assert!(
7028 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7029 "{why}"
7030 );
7031 assert_eq!(state.status, RunStatus::Blocked);
7032 }
7033
7034 #[test]
7035 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7036 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7037 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7038 assert_eq!(bound_head("old", "old", Some("new")), None);
7040 assert_eq!(bound_head("new", "old", Some("new")), None);
7042 assert_eq!(bound_head("new", "old", None), None);
7043 assert_eq!(bound_head("", "", None), None);
7045 assert_eq!(bound_head("", "", Some("new")), None);
7046 assert_eq!(bound_head("abc", "", None), None);
7047 }
7048
7049 fn view_json(head: &str) -> String {
7050 format!(
7051 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7052 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7053 "reviews":[],"comments":[]}}"#
7054 )
7055 }
7056
7057 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7058 format!(
7059 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7060 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7061 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7062 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7063 )
7064 }
7065
7066 #[test]
7067 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7068 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7071 assert_eq!(s.rollup_head, "old");
7072 assert_eq!(s.pr.checks, Checks::Red);
7073 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7074 assert_eq!(s.failing_urls.len(), 1);
7075 }
7076
7077 #[test]
7078 fn checks_come_from_the_node_not_the_view() {
7079 let view = view_json("new").replace(
7080 r#""reviews""#,
7081 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7082 );
7083 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7084 assert_eq!(s.pr.checks, Checks::Green);
7085 assert!(s.pr.failing.is_empty());
7086 assert_eq!(
7087 bound_head(&s.head, &s.rollup_head, Some("new")),
7088 Some("new")
7089 );
7090 }
7091
7092 #[test]
7093 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7094 for node in [
7095 None,
7096 Some("not json".to_owned()),
7097 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7098 Some(node_json("new", "SUCCESS", true)),
7099 ] {
7100 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7101 assert!(s.rollup_head.is_empty());
7102 assert_eq!(s.pr.checks, Checks::Unknown);
7103 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7104 }
7105 }
7106
7107 #[test]
7108 fn the_merge_command_is_pinned_to_the_observed_head() {
7109 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7110 let at = argv
7111 .iter()
7112 .position(|a| a == "--match-head-commit")
7113 .unwrap();
7114 assert_eq!(argv[at + 1], "deadbeef");
7115 }
7116
7117 #[tokio::test]
7118 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7119 let mut state = landing_state();
7120 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7123 stale.rollup_head = "old".to_owned();
7124 let forge = Scripted::new(
7125 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7126 vec![],
7127 );
7128 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7129 head: "new".to_owned(),
7130 });
7131 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7132 .await
7133 .unwrap();
7134 assert!(!forge.calls().contains(&"merge"));
7135 assert_eq!(state.status, RunStatus::Blocked);
7136 let why = state.merge.as_ref().unwrap().detail.clone();
7137 assert!(why.contains("new") && why.contains("old"), "{why}");
7138 }
7139
7140 #[test]
7141 fn a_refusal_read_against_another_commits_checks_is_pending() {
7142 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7143 after.rollup_head = "old".to_owned();
7144 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7145 }
7146
7147 #[tokio::test]
7148 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7149 let mut state = landing_state();
7150 let forge = Scripted::new(
7151 vec![seen("a", Checks::Red, "CLEAN", false)],
7152 vec![(true, "")],
7153 );
7154 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7155 .await
7156 .unwrap();
7157 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7158 assert_eq!(state.status, RunStatus::Merged);
7159 }
7160
7161 #[tokio::test]
7162 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7163 let mut state = landing_state();
7164 let forge = Scripted::new(
7165 vec![
7166 seen("a", Checks::Green, "CLEAN", false),
7167 seen("a", Checks::Green, "CLEAN", false),
7168 seen("b", Checks::Green, "BLOCKED", false),
7170 seen("b", Checks::Green, "CLEAN", false),
7171 ],
7172 vec![(false, REFUSED), (true, "")],
7173 );
7174 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7175 .await
7176 .unwrap();
7177 assert_eq!(
7178 forge.calls(),
7179 [
7180 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7181 ]
7182 );
7183 assert_eq!(state.status, RunStatus::Merged);
7184 }
7185
7186 fn merged_view(head: &str) -> Seen {
7187 let mut m = seen(head, Checks::Green, "CLEAN", false);
7188 m.pr.state = PrLifecycle::Merged;
7189 m
7190 }
7191
7192 fn has(argv: &[String], flag: &str) -> bool {
7193 argv.iter().any(|a| a == flag)
7194 }
7195
7196 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7197 let at = argv.iter().position(|a| a == flag)?;
7198 argv.get(at + 1).map(String::as_str)
7199 }
7200
7201 const URL: &str = "https://github.com/o/r/pull/42";
7202
7203 #[tokio::test]
7204 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7205 let mut state = landing_state();
7206 let forge = Scripted::new(
7207 vec![
7208 seen("abc", Checks::Green, "CLEAN", false),
7209 seen("abc", Checks::Green, "CLEAN", false),
7210 ],
7211 vec![(true, "")],
7212 );
7213 land_with(&mut state, URL, &forge).await.unwrap();
7214 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7215 let argv = &forge.argvs()[0];
7216 assert!(has(argv, "--squash") && has(argv, "--subject"));
7217 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7218 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7219 assert_eq!(state.status, RunStatus::Merged);
7220 assert!(state.land_armed_head.is_none());
7221 }
7222
7223 #[tokio::test]
7224 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7225 let mut state = landing_state();
7226 state.land_armed_head = Some("a".to_owned());
7227 let forge = Scripted::new(
7228 vec![seen("a", Checks::Green, "CLEAN", false)],
7229 vec![(true, ""), (true, "")],
7230 );
7231 land_with(&mut state, URL, &forge).await.unwrap();
7232 let argvs = forge.argvs();
7233 assert!(has(&argvs[0], "--disable-auto"));
7234 assert!(!has(&argvs[1], "--auto"));
7235 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7236 assert_eq!(state.status, RunStatus::Merged);
7237 assert!(state.land_armed_head.is_none());
7238 }
7239
7240 #[tokio::test]
7241 async fn an_approval_never_carries_over_to_a_new_head() {
7242 crate::run::pin_test_home();
7243 let mut state = run_state();
7244 state.config.graph.land_approval = true;
7245 let pr = green_pr();
7246 let store = ask::Questions::open();
7247
7248 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7249 .await
7250 .unwrap();
7251 let mut q = store
7252 .list()
7253 .into_iter()
7254 .find(|q| q.run == state.id)
7255 .unwrap();
7256 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7257 store.put(&mut q).unwrap();
7258 assert_eq!(
7259 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7260 .await
7261 .unwrap(),
7262 ApprovalGate::Approved,
7263 "the same head keeps its approval"
7264 );
7265
7266 assert_eq!(
7268 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7269 .await
7270 .unwrap(),
7271 ApprovalGate::Pending
7272 );
7273 let all: Vec<_> = store
7274 .list()
7275 .into_iter()
7276 .filter(|q| q.run == state.id)
7277 .collect();
7278 assert_eq!(all.len(), 2);
7279
7280 state.land_approval = None;
7282 assert_eq!(
7283 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7284 .await
7285 .unwrap(),
7286 ApprovalGate::Pending
7287 );
7288 let open = store
7289 .list()
7290 .into_iter()
7291 .filter(|q| q.run == state.id && q.status.open())
7292 .count();
7293 assert_eq!(open, 1, "the superseded question was retired");
7294 }
7295
7296 #[tokio::test]
7297 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7298 let mut state = landing_state();
7299 let forge = Scripted::new(
7300 vec![
7301 seen("a", Checks::Green, "CLEAN", false),
7302 seen("b", Checks::Green, "CLEAN", false),
7304 ],
7305 vec![(true, "")],
7306 );
7307 land_with(&mut state, URL, &forge).await.unwrap();
7308 let argvs = forge.argvs();
7309 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7310 assert!(!has(&argvs[0], "--auto"));
7311 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7312 assert_eq!(state.status, RunStatus::Merged);
7313 }
7314
7315 fn passing(label: &str) -> CheckInfo {
7316 CheckInfo {
7317 label: label.to_owned(),
7318 verdict: Verdict::Pass,
7319 required: Some(false),
7320 run: None,
7321 }
7322 }
7323
7324 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7325 (label.to_owned(), run.map(str::to_owned))
7326 }
7327
7328 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7329 LandReruns {
7330 head: head.to_owned(),
7331 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7332 }
7333 }
7334
7335 #[test]
7336 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7337 let f = [failing("test (macos)", Some("7"))];
7338 assert_eq!(
7339 rerun_step(&f, None, "h", 100),
7340 RerunStep::Rerun(vec!["7".to_owned()])
7341 );
7342 let rec = reran("h", &[("7", 100)]);
7343 let grace = crate::release_watch::RERUN_GRACE;
7344 assert_eq!(
7345 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7346 RerunStep::Grace
7347 );
7348 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7349 }
7350
7351 #[test]
7352 fn a_failure_without_a_run_id_goes_to_the_fixer() {
7353 let f = [failing("a", Some("7")), failing("ci/external", None)];
7354 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7355 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7356 }
7357
7358 #[test]
7359 fn a_moved_head_forgets_the_reruns() {
7360 let f = [failing("a", Some("7"))];
7361 let rec = reran("old", &[("7", 100)]);
7362 assert_eq!(
7363 rerun_step(&f, Some(&rec), "new", 101),
7364 RerunStep::Rerun(vec!["7".to_owned()])
7365 );
7366 }
7367
7368 #[test]
7369 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7370 let f = [
7371 failing("a", Some("7")),
7372 failing("b", Some("7")),
7373 failing("c", Some("8")),
7374 ];
7375 assert_eq!(
7376 rerun_step(&f, None, "h", 0),
7377 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7378 );
7379 let rec = reran("h", &[("7", 0)]);
7380 assert_eq!(
7381 rerun_step(&f, Some(&rec), "h", 1),
7382 RerunStep::Rerun(vec!["8".to_owned()])
7383 );
7384 }
7385
7386 #[test]
7387 fn only_blocking_failures_are_candidates_for_a_rerun() {
7388 let check = |label: &str, verdict, required, run: &str| CheckInfo {
7389 label: label.to_owned(),
7390 verdict,
7391 required,
7392 run: Some(run.to_owned()),
7393 };
7394 let got = rerunnable(&[
7395 check("passed", Verdict::Pass, Some(true), "1"),
7396 check("optional", Verdict::Fail, Some(false), "2"),
7397 check("required", Verdict::Fail, Some(true), "3"),
7398 check("unknown", Verdict::Fail, None, "4"),
7399 ]);
7400 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7401 assert_eq!(labels, ["required", "unknown"]);
7402 }
7403
7404 fn red(head: &str, run: Option<&str>) -> Seen {
7405 let mut s = seen(head, Checks::Red, "BLOCKED", false);
7406 s.pr.failing = vec!["test (macos)".to_owned()];
7407 s.contexts = vec![CheckInfo {
7408 label: "test (macos)".to_owned(),
7409 verdict: Verdict::Fail,
7410 required: Some(true),
7411 run: run.map(str::to_owned),
7412 }];
7413 s
7414 }
7415
7416 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7417
7418 #[tokio::test]
7419 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7420 let mut state = landing_state();
7421 let forge = Scripted::new(
7422 vec![
7423 red("a", Some("7")),
7424 seen("a", Checks::Pending, "BLOCKED", false),
7425 seen("a", Checks::Green, "CLEAN", false),
7426 ],
7427 vec![(true, "")],
7428 );
7429 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7430 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7431 assert!(!forge.calls().contains(&"fix"));
7432 assert_eq!(state.status, RunStatus::Merged);
7433 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7434 let argvs = forge.argvs();
7435 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7436 assert!(
7437 state
7438 .events
7439 .iter()
7440 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7441 );
7442 }
7443
7444 #[tokio::test]
7445 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7446 let mut state = landing_state();
7447 let forge = Scripted::new(
7448 vec![
7449 red("a", Some("7")),
7450 seen("a", Checks::Green, "CLEAN", false),
7451 ],
7452 vec![(true, "")],
7453 );
7454 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7455 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7457 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7458 let calls = forge.calls();
7459 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7460 let fix_at = calls.iter().position(|c| *c == "fix");
7461 assert!(fix_at.is_none_or(|f| f > rerun_at));
7462 }
7463
7464 #[tokio::test]
7465 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7466 let mut state = landing_state();
7467 let mut v = red("a", Some("7"));
7468 v.pr.checks = Checks::Pending;
7469 v.contexts.push(CheckInfo {
7470 label: "slow".to_owned(),
7471 verdict: Verdict::Pending,
7472 required: Some(true),
7473 run: Some("8".to_owned()),
7474 });
7475 let forge = Scripted::new(
7476 vec![v, seen("a", Checks::Green, "CLEAN", false)],
7477 vec![(true, "")],
7478 );
7479 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7480 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7481 assert_eq!(state.status, RunStatus::Merged);
7482 }
7483
7484 #[test]
7485 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7486 let mut v = red("a", Some("7"));
7487 v.contexts.push(CheckInfo {
7488 label: "same run, still going".to_owned(),
7489 verdict: Verdict::Pending,
7490 required: Some(true),
7491 run: Some("7".to_owned()),
7492 });
7493 assert!(rerunnable(&v.contexts).is_empty());
7494 }
7495
7496 #[tokio::test]
7497 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7498 let mut state = landing_state();
7499 state.config.graph.land_rounds = 0;
7500 let forge = Scripted::new(
7501 vec![
7502 red("a", Some("7")),
7503 seen("a", Checks::Green, "CLEAN", false),
7504 ],
7505 vec![(true, "")],
7506 );
7507 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7508 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7509 assert_eq!(state.status, RunStatus::Merged);
7510 assert!(!forge.calls().contains(&"fix"));
7511 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7512 }
7513
7514 #[tokio::test]
7515 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7516 let mut state = landing_state();
7517 state.config.graph.land_rounds = 1;
7518 let forge = Scripted::new(
7519 vec![
7520 seen("a", Checks::Green, "CLEAN", true),
7521 red("b", Some("7")),
7522 seen("b", Checks::Green, "CLEAN", false),
7523 ],
7524 vec![(true, "")],
7525 );
7526 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7527 head: "b".to_owned(),
7528 });
7529 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7530 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7531 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7532 assert_eq!(state.pr.as_ref().unwrap().round, 1);
7533 assert_eq!(state.status, RunStatus::Merged);
7534 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7535 assert_eq!(
7536 value_of(&forge.argvs()[0], "--match-head-commit"),
7537 Some("b")
7538 );
7539 }
7540
7541 #[tokio::test]
7542 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7543 let mut state = landing_state();
7544 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7545 *forge.rerun_fails.lock().unwrap() = true;
7546 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7547 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7548 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7549 assert_eq!(
7550 forge.calls().iter().filter(|c| **c == "fix").count(),
7551 1,
7552 "{:?}",
7553 forge.calls()
7554 );
7555 }
7556
7557 #[tokio::test]
7558 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7559 let mut state = landing_state();
7560 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7561 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7563 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7564 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7565 assert!(forge.reruns.lock().unwrap().is_empty());
7566 }
7567
7568 #[tokio::test]
7569 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7570 let mut state = landing_state();
7571 state.config.graph.rerun_flaky_checks = false;
7572 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7573 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7574 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7575 assert!(forge.reruns.lock().unwrap().is_empty());
7576 assert!(forge.calls().contains(&"fix"));
7577
7578 let mut state = landing_state();
7579 let forge = Scripted::new(vec![red("a", None)], vec![]);
7580 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7581 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7582 assert!(forge.reruns.lock().unwrap().is_empty());
7583 assert!(forge.calls().contains(&"fix"));
7584 }
7585
7586 fn names(xs: &[&str]) -> BTreeSet<String> {
7587 xs.iter().map(|x| (*x).to_owned()).collect()
7588 }
7589
7590 #[test]
7591 fn a_required_check_the_rollup_never_listed_is_named() {
7592 let req = names(&["build"]);
7593 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7594 assert!(why.contains("never reported: build"), "{why}");
7595 assert!(!why.contains("probably waiting for a review"), "{why}");
7596 }
7597
7598 #[test]
7599 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7600 let why = waiting_on("BLOCKED", &[passing("review")], None);
7601 assert!(why.contains("could not be read"), "{why}");
7602 assert!(!why.contains("probably waiting for a review"), "{why}");
7603 }
7604
7605 #[test]
7606 fn all_required_reported_keeps_the_review_guess() {
7607 let req = names(&["build"]);
7608 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7609 assert!(why.contains("probably waiting for a review"), "{why}");
7610 assert!(!why.contains("never reported"), "{why}");
7611 }
7612
7613 #[test]
7614 fn required_names_match_the_rollup_ignoring_case_only() {
7615 let req = names(&["Build"]);
7616 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7617 assert!(!why.contains("never reported"), "{why}");
7618 }
7619
7620 #[test]
7621 fn required_contexts_are_read_from_protection_and_rulesets() {
7622 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7623 assert_eq!(
7624 parse_classic_required(classic),
7625 Some(names(&["build", "lint"]))
7626 );
7627 let rules = r#"[{"type":"pull_request","parameters":{}},
7628 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7629 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7630 assert_eq!(parse_ruleset_required("nope"), None);
7631 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7632 }
7633
7634 #[test]
7635 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7636 let shown = BTreeSet::new();
7637 let ok = seen("a", Checks::Green, "CLEAN", false);
7638 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7639 assert!(guard(Some(&ok)));
7640 assert!(!guard(None));
7641 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7642 let mut stale = ok.clone();
7643 stale.rollup_head = "old".to_owned();
7644 assert!(!guard(Some(&stale)));
7645 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7646 assert!(!guard(Some(&merged_view("a"))));
7647 }
7648
7649 #[test]
7650 fn the_rollup_node_carries_whether_each_check_is_required() {
7651 let node = node_json("new", "SUCCESS", false)
7652 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7653 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7654 assert_eq!(s.contexts.len(), 1);
7655 assert_eq!(s.contexts[0].required, Some(true));
7656 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7657 assert_eq!(s.contexts[0].required, None);
7658 }
7659
7660 #[tokio::test]
7661 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7662 let mut state = landing_state();
7663 state.land_armed_head = Some("a".to_owned());
7664 let forge = Scripted::new(
7665 vec![seen("a", Checks::Green, "CLEAN", true)],
7666 vec![(false, "disable exploded")],
7667 );
7668 land_with(&mut state, URL, &forge).await.unwrap();
7669 assert!(!forge.calls().contains(&"fix"));
7670 assert_eq!(state.status, RunStatus::Blocked);
7671 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7672 let why = state.merge.as_ref().unwrap().detail.clone();
7673 assert!(why.contains("disable exploded"), "{why}");
7674 }
7675}