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_rule(
4349 language,
4350 &state.config.graph.github_language,
4351 ));
4352 if let Some(overlay) = state.config.prompts.overlay("fix") {
4353 let _ = write!(s, "\n\n{overlay}");
4354 }
4355 s
4356}
4357
4358async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4361 let mut out = String::new();
4362 for (name, url) in failing.iter().take(MAX_LOGS) {
4363 let args = match (job_of(url), run_of(url)) {
4364 (Some(job), _) => vec![
4365 "run".to_owned(),
4366 "view".to_owned(),
4367 "--log-failed".to_owned(),
4368 "--job".to_owned(),
4369 job,
4370 ],
4371 (None, Some(run)) => vec![
4372 "run".to_owned(),
4373 "view".to_owned(),
4374 run,
4375 "--log-failed".to_owned(),
4376 ],
4377 (None, None) => continue,
4379 };
4380 let (ok, body) = match gh(repo, &args).await {
4381 Ok(v) => v,
4382 Err(e) => (false, format!("{e:#}")),
4383 };
4384 if !ok && body.trim().is_empty() {
4385 continue;
4386 }
4387 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4388 }
4389 out
4390}
4391
4392fn job_of(details_url: &str) -> Option<String> {
4395 let after = details_url.split("/job/").nth(1)?;
4396 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4397 (!id.is_empty()).then_some(id)
4398}
4399
4400pub(crate) fn run_of(details_url: &str) -> Option<String> {
4402 let after = details_url.split("/actions/runs/").nth(1)?;
4403 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4404 (!id.is_empty()).then_some(id)
4405}
4406
4407fn stop_comment(run_id: &str, why: &str) -> String {
4411 format!(
4412 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4413 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4414 )
4415}
4416
4417async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4422 let body = stop_comment(&state.id, why);
4423 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4424 if !body.contains(MARKER) {
4425 body = format!("{MARKER}\n{body}");
4426 }
4427 let posted = gh(
4428 repo,
4429 &[
4430 "pr".to_owned(),
4431 "comment".to_owned(),
4432 pr.number.to_string(),
4433 "--body".to_owned(),
4434 body,
4435 ],
4436 )
4437 .await;
4438 match posted {
4439 Ok((true, _)) => {}
4440 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4441 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4442 }
4443 state.status = RunStatus::Blocked;
4444 state.merge = Some(MergeOutcome {
4445 mode: MergeMode::Pr,
4446 ok: false,
4447 detail: why.to_owned(),
4448 empty: false,
4449 });
4450 state.event("land", format!("stopped: {why}"));
4451 state.save()?;
4452 Ok(())
4453}
4454
4455pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4471 let out = tokio::process::Command::new("gh")
4472 .args(args)
4473 .current_dir(cwd)
4474 .env_remove("GH_REPO")
4475 .quiet()
4476 .stdin(std::process::Stdio::null())
4477 .output()
4478 .await
4479 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4480 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4481 let err = String::from_utf8_lossy(&out.stderr);
4482 if body.trim().is_empty() {
4483 body = err.into_owned();
4484 } else if !err.trim().is_empty() {
4485 body.push_str(&err);
4486 }
4487 Ok((out.status.success(), body.trim().to_owned()))
4488}
4489
4490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4492pub(crate) enum Verdict {
4493 Pass,
4494 Fail,
4495 Pending,
4496 Unknown,
4497}
4498
4499#[derive(Debug, Deserialize)]
4500#[serde(rename_all = "camelCase")]
4501struct GhPr {
4502 #[serde(default)]
4503 url: String,
4504 #[serde(default)]
4505 number: u64,
4506 #[serde(default)]
4507 state: String,
4508 #[serde(default)]
4509 title: String,
4510 #[serde(default)]
4511 status_check_rollup: Vec<GhCheck>,
4512 #[serde(default)]
4519 merge_state_status: String,
4520 #[serde(default)]
4524 head_ref_oid: String,
4525 #[serde(default)]
4526 base_ref_name: String,
4527 #[serde(default)]
4528 reviews: Vec<GhReview>,
4529 #[serde(default)]
4530 comments: Vec<GhComment>,
4531}
4532
4533#[derive(Debug, Deserialize)]
4538#[serde(rename_all = "camelCase")]
4539struct GhCheck {
4540 #[serde(default)]
4541 name: Option<String>,
4542 #[serde(default)]
4543 context: Option<String>,
4544 #[serde(default)]
4545 status: Option<String>,
4546 #[serde(default)]
4547 conclusion: Option<String>,
4548 #[serde(default)]
4549 state: Option<String>,
4550 #[serde(default)]
4551 details_url: Option<String>,
4552 #[serde(default)]
4553 target_url: Option<String>,
4554 #[serde(default)]
4557 is_required: Option<bool>,
4558}
4559
4560impl GhCheck {
4561 fn label(&self) -> String {
4563 self.name
4564 .clone()
4565 .or_else(|| self.context.clone())
4566 .unwrap_or_else(|| "(unnamed check)".to_owned())
4567 }
4568
4569 fn url(&self) -> Option<&str> {
4571 self.details_url
4572 .as_deref()
4573 .or(self.target_url.as_deref())
4574 .filter(|u| !u.is_empty())
4575 }
4576
4577 fn verdict(&self) -> Verdict {
4585 if let Some(status) = self.status.as_deref() {
4586 if !status.eq_ignore_ascii_case("COMPLETED") {
4587 return Verdict::Pending;
4588 }
4589 }
4590 let outcome = self
4591 .conclusion
4592 .as_deref()
4593 .or(self.state.as_deref())
4594 .unwrap_or("");
4595 match outcome.to_ascii_uppercase().as_str() {
4596 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4597 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4598 | "ACTION_REQUIRED" => Verdict::Fail,
4599 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4600 Verdict::Pending
4601 }
4602 _ => Verdict::Unknown,
4603 }
4604 }
4605}
4606
4607#[derive(Debug, Deserialize)]
4608struct GhAuthor {
4609 #[serde(default)]
4610 login: String,
4611}
4612
4613#[derive(Debug, Deserialize)]
4614struct GhReview {
4615 #[serde(default)]
4616 author: GhAuthor,
4617 #[serde(default)]
4618 body: String,
4619}
4620
4621#[derive(Debug, Deserialize)]
4622struct GhComment {
4623 #[serde(default)]
4624 author: GhAuthor,
4625 #[serde(default)]
4626 body: String,
4627}
4628
4629#[derive(Debug, Deserialize)]
4630struct GhUser {
4631 #[serde(default)]
4632 login: String,
4633}
4634
4635#[derive(Debug, Deserialize)]
4636struct GhInline {
4637 #[serde(default)]
4638 user: GhUser,
4639 #[serde(default)]
4640 path: Option<String>,
4641 #[serde(default)]
4642 line: Option<u64>,
4643 #[serde(default)]
4644 body: String,
4645}
4646
4647impl Default for GhAuthor {
4648 fn default() -> Self {
4649 Self {
4650 login: "(unknown)".to_owned(),
4651 }
4652 }
4653}
4654
4655impl Default for GhUser {
4656 fn default() -> Self {
4657 Self {
4658 login: "(unknown)".to_owned(),
4659 }
4660 }
4661}
4662
4663#[cfg(test)]
4664mod tests {
4665 use super::*;
4666 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4667
4668 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4669 format!(
4670 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4671 )
4672 }
4673
4674 #[test]
4675 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4676 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4677 assert_eq!(
4678 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4679 Ok(())
4680 );
4681 for (json, why) in [
4682 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4683 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4684 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4685 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4686 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4687 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4688 ] {
4689 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4690 .unwrap_err()
4691 .why;
4692 assert!(err.contains(why), "{json}: {err}");
4693 }
4694 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4696 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4697 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4698 assert!(
4699 !closable(
4700 &head_json("x", "main", "OPEN", false),
4701 "magi/27b2/A",
4702 "main",
4703 &[]
4704 )
4705 .unwrap_err()
4706 .retry
4707 );
4708 assert!(is_forge_url("https://github.com/o/r.git"));
4709 assert!(is_forge_url("git@github.com:o/r.git"));
4710 assert!(!is_forge_url("/tmp/origin.git"));
4711 assert!(!is_forge_url("C:\\work\\origin.git"));
4712 assert!(!is_forge_url("file:///tmp/origin.git"));
4713 assert!(forge_unavailable(
4714 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4715 ));
4716 assert!(!forge_unavailable(
4717 "gh pr list failed: error connecting to api.github.com"
4718 ));
4719 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4720 assert!(
4722 closable(
4723 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4724 "b",
4725 "main",
4726 &["aaa".to_owned()]
4727 )
4728 .is_err()
4729 );
4730 }
4731
4732 #[test]
4733 fn the_close_comment_names_the_commit_on_the_base() {
4734 let e = crate::already::Evidence {
4735 proof: crate::already::Proof::PatchId,
4736 tip: "1234567890".to_owned(),
4737 commits: vec!["0e368de0000".to_owned()],
4738 };
4739 let c = superseded_comment("main", &e);
4740 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4741 }
4742
4743 const GREEN_OPEN: &str = r####"{
4745 "url": "https://github.com/yukimemi/magi/pull/10",
4746 "number": 10,
4747 "state": "OPEN",
4748 "mergeStateStatus": "CLEAN",
4749 "statusCheckRollup": [
4750 {
4751 "__typename": "CheckRun",
4752 "conclusion": "SKIPPED",
4753 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4754 "name": "review",
4755 "status": "COMPLETED",
4756 "workflowName": "claude-review"
4757 },
4758 {
4759 "__typename": "CheckRun",
4760 "conclusion": "SUCCESS",
4761 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4762 "name": "check (ubuntu-latest)",
4763 "status": "COMPLETED",
4764 "workflowName": "CI"
4765 },
4766 {
4767 "__typename": "CheckRun",
4768 "conclusion": "SUCCESS",
4769 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4770 "name": "rustfmt",
4771 "status": "COMPLETED",
4772 "workflowName": "CI"
4773 },
4774 {
4775 "__typename": "StatusContext",
4776 "context": "CodeRabbit",
4777 "state": "SUCCESS",
4778 "targetUrl": ""
4779 }
4780 ],
4781 "reviews": [],
4782 "comments": [
4783 {
4784 "author": {
4785 "login": "coderabbitai"
4786 },
4787 "authorAssociation": "NONE",
4788 "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"
4789 }
4790 ]
4791}"####;
4792
4793 const RED_OPEN: &str = r####"{
4795 "url": "https://github.com/yukimemi/magi/pull/9",
4796 "number": 9,
4797 "state": "OPEN",
4798 "mergeStateStatus": "UNSTABLE",
4799 "statusCheckRollup": [
4800 {
4801 "__typename": "CheckRun",
4802 "conclusion": "SUCCESS",
4803 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4804 "name": "check (ubuntu-latest)",
4805 "status": "COMPLETED",
4806 "workflowName": "CI"
4807 },
4808 {
4809 "__typename": "CheckRun",
4810 "conclusion": "SUCCESS",
4811 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4812 "name": "rustfmt",
4813 "status": "COMPLETED",
4814 "workflowName": "CI"
4815 },
4816 {
4817 "__typename": "CheckRun",
4818 "conclusion": "FAILURE",
4819 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4820 "name": "editorconfig",
4821 "status": "COMPLETED",
4822 "workflowName": "CI"
4823 },
4824 {
4825 "__typename": "StatusContext",
4826 "context": "CodeRabbit",
4827 "state": "SUCCESS",
4828 "targetUrl": ""
4829 }
4830 ],
4831 "reviews": [],
4832 "comments": [
4833 {
4834 "author": {
4835 "login": "coderabbitai"
4836 },
4837 "authorAssociation": "NONE",
4838 "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"
4839 }
4840 ]
4841}"####;
4842
4843 const PENDING_OPEN: &str = r####"{
4845 "url": "https://github.com/yukimemi/magi/pull/9",
4846 "number": 9,
4847 "state": "OPEN",
4848 "statusCheckRollup": [
4849 {
4850 "__typename": "CheckRun",
4851 "conclusion": "SUCCESS",
4852 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4853 "name": "check (ubuntu-latest)",
4854 "status": "COMPLETED",
4855 "workflowName": "CI"
4856 },
4857 {
4858 "__typename": "CheckRun",
4859 "conclusion": "SUCCESS",
4860 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4861 "name": "rustfmt",
4862 "status": "COMPLETED",
4863 "workflowName": "CI"
4864 },
4865 {
4866 "__typename": "CheckRun",
4867 "conclusion": null,
4868 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4869 "name": "editorconfig",
4870 "status": "IN_PROGRESS",
4871 "workflowName": "CI"
4872 },
4873 {
4874 "__typename": "StatusContext",
4875 "context": "CodeRabbit",
4876 "state": "SUCCESS",
4877 "targetUrl": ""
4878 }
4879 ],
4880 "reviews": [],
4881 "comments": []
4882}"####;
4883
4884 const MERGED: &str = r####"{
4886 "url": "https://github.com/yukimemi/magi/pull/16",
4887 "number": 16,
4888 "state": "MERGED",
4889 "statusCheckRollup": [
4890 {
4891 "__typename": "CheckRun",
4892 "conclusion": "SUCCESS",
4893 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4894 "name": "check (ubuntu-latest)",
4895 "status": "COMPLETED",
4896 "workflowName": "CI"
4897 },
4898 {
4899 "__typename": "CheckRun",
4900 "conclusion": "SUCCESS",
4901 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4902 "name": "review",
4903 "status": "COMPLETED",
4904 "workflowName": "claude-review"
4905 }
4906 ],
4907 "reviews": [],
4908 "comments": []
4909}"####;
4910
4911 const REVIEWED_OPEN: &str = r####"{
4913 "url": "https://github.com/yukimemi/magi/pull/12",
4914 "number": 12,
4915 "state": "OPEN",
4916 "statusCheckRollup": [
4917 {
4918 "__typename": "CheckRun",
4919 "conclusion": "SUCCESS",
4920 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4921 "name": "check (ubuntu-latest)",
4922 "status": "COMPLETED",
4923 "workflowName": "CI"
4924 },
4925 {
4926 "__typename": "CheckRun",
4927 "conclusion": "SUCCESS",
4928 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4929 "name": "review",
4930 "status": "COMPLETED",
4931 "workflowName": "claude-review"
4932 }
4933 ],
4934 "reviews": [
4935 {
4936 "author": {
4937 "login": "claude"
4938 },
4939 "state": "COMMENTED",
4940 "body": ""
4941 }
4942 ],
4943 "comments": [
4944 {
4945 "author": {
4946 "login": "coderabbitai"
4947 },
4948 "authorAssociation": "NONE",
4949 "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"
4950 },
4951 {
4952 "author": {
4953 "login": "claude"
4954 },
4955 "authorAssociation": "NONE",
4956 "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"
4957 }
4958 ]
4959}"####;
4960
4961 const INLINE: &str = r####"[
4963 {
4964 "user": {
4965 "login": "claude[bot]"
4966 },
4967 "path": "src/graph.rs",
4968 "line": 231,
4969 "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"
4970 }
4971]"####;
4972
4973 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4975<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4976
4977> [!IMPORTANT]
4978> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4979>
4980> This repository does not receive automatic reviews because it has fewer than 10 stars.
4981>
4982> <details>
4983> <summary>⚙️ Run configuration</summary>
4984>
4985> **Configuration used**: defaults
4986>
4987> **Review profile**: CHILL
4988>
4989> **Plan**: Team
4990>
4991> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4992>
4993> </details>
4994
4995<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4996
4997<!-- tips_start -->
4998
4999---
5000
5001Thanks 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.
5002
5003<details>
5004<summary>❤️ Share</summary>
5005
5006- [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"####;
5007
5008 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5010
5011---
5012### Reviewing PR #16
5013
5014- [x] Read AGENTS.md conventions
5015- [x] Review `src/daemon.rs` changes
5016- [x] Review `src/main.rs` changes (new `doctor` reporting)
5017- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5018- [x] Check test coverage for new behavior
5019- [x] Run verification commands (blocked — see note)
5020- [x] Post findings"####;
5021
5022 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5024
5025---
5026### Review: `magi review <branch>` — cheap-half-only graph
5027
5028Read 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.
5029
5030**Correctness**
5031
5032- 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"####;
5033
5034 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5035 PrState {
5036 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5037 number: 16,
5038 state: PrLifecycle::Open,
5039 checks,
5040 blocking: if matches!(checks, Checks::Red) {
5044 Blocking::Yes
5045 } else {
5046 Blocking::No
5047 },
5048 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5049 review_comments: (0..comments)
5050 .map(|i| ReviewComment {
5051 author: "coderabbitai".to_owned(),
5052 path: Some("src/graph.rs".to_owned()),
5053 line: Some(231),
5054 body: format!("finding {i}"),
5055 })
5056 .collect(),
5057 }
5058 }
5059
5060 #[test]
5061 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5062 use CiExpectation::{Absent, Expected};
5063 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5064 let p = pr(checks, &["x"], 0);
5065 for waited in [Duration::ZERO, CHECKS_GRACE] {
5066 assert_eq!(
5067 decide_with(&p, 0, 4, waited, Expected),
5068 decide(&p, 0, 4, waited)
5069 );
5070 }
5071 }
5072 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5074 let p = pr(checks, &["x"], 0);
5075 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5076 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5077 }
5078 let mut p = pr(Checks::Unknown, &[], 0);
5080 p.blocking = Blocking::Conflict;
5081 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5082 p.state = PrLifecycle::Merged;
5084 assert_eq!(
5085 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5086 Step::Done { merged: true }
5087 );
5088 }
5089
5090 #[test]
5091 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5092 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5093 assert_eq!(state.number, 10);
5094 assert_eq!(state.state, PrLifecycle::Open);
5095 assert_eq!(state.checks, Checks::Green);
5096 assert!(state.failing.is_empty());
5097 assert!(
5098 state.review_comments.is_empty(),
5099 "the only comment is CodeRabbit's trigger notice: {:?}",
5100 state.review_comments
5101 );
5102 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5103 }
5104
5105 #[test]
5106 fn a_failing_check_parses_as_red_and_is_named() {
5107 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5108 assert_eq!(state.checks, Checks::Red);
5109 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5110 let mut blocking = state.clone();
5117 blocking.blocking = Blocking::Yes;
5118 match decide(&blocking, 0, 4, Duration::ZERO) {
5119 Step::Fix { reason } => {
5120 assert!(reason.contains("editorconfig"), "reason: {reason}");
5121 assert!(reason.contains("failing"), "reason: {reason}");
5122 }
5123 other => panic!("expected a fix round, got {other:?}"),
5124 }
5125 }
5126
5127 #[test]
5128 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5129 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5130 assert_eq!(state.checks, Checks::Pending);
5131 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5132 }
5133
5134 #[test]
5135 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5136 let state = parse_pr(MERGED).expect("merged fixture parses");
5137 assert_eq!(state.state, PrLifecycle::Merged);
5138 assert_eq!(
5139 decide(&state, 0, 4, Duration::ZERO),
5140 Step::Done { merged: true }
5141 );
5142 }
5143
5144 #[test]
5145 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5146 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5147 assert_eq!(state.checks, Checks::Green);
5148 let authors: Vec<&str> = state
5149 .review_comments
5150 .iter()
5151 .map(|c| c.author.as_str())
5152 .collect();
5153 assert_eq!(
5154 authors,
5155 vec!["claude"],
5156 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5157 );
5158 match decide(&state, 0, 4, Duration::ZERO) {
5159 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5160 other => panic!("expected a fix round, got {other:?}"),
5161 }
5162 }
5163
5164 #[test]
5165 fn inline_review_comments_keep_their_file_and_line() {
5166 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5167 assert_eq!(comments.len(), 1);
5168 assert_eq!(comments[0].author, "claude[bot]");
5169 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5170 assert_eq!(comments[0].line, Some(231));
5171 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5172 }
5173
5174 #[test]
5175 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5176 assert!(
5177 is_noise(CODERABBIT_TRIGGER),
5178 "CodeRabbit's trigger notice declares itself not a review"
5179 );
5180 assert!(
5181 is_noise(CLAUDE_CHECKLIST),
5182 "a progress checklist asks for nothing"
5183 );
5184 assert!(
5185 !is_noise(CLAUDE_FINDING),
5186 "a review that names a bug is input, not noise"
5187 );
5188
5189 let mut clean = pr(Checks::Green, &[], 0);
5190 clean.review_comments.push(ReviewComment {
5191 author: "coderabbitai".to_owned(),
5192 path: None,
5193 line: None,
5194 body: CODERABBIT_TRIGGER.to_owned(),
5195 });
5196 clean.review_comments.retain(|c| !is_noise(&c.body));
5197 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5198
5199 let mut found = pr(Checks::Green, &[], 0);
5200 found.review_comments.push(ReviewComment {
5201 author: "claude".to_owned(),
5202 path: None,
5203 line: None,
5204 body: CLAUDE_FINDING.to_owned(),
5205 });
5206 found.review_comments.retain(|c| !is_noise(&c.body));
5207 assert!(matches!(
5208 decide(&found, 0, 4, Duration::ZERO),
5209 Step::Fix { .. }
5210 ));
5211 }
5212
5213 #[test]
5214 fn the_policy_table_holds_for_every_combination_that_matters() {
5215 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5216 (
5217 "pending checks are waited for, even on the last round",
5218 pr(Checks::Pending, &[], 0),
5219 4,
5220 4,
5221 Duration::ZERO,
5222 Step::Wait,
5223 ),
5224 (
5225 "red checks are fixed",
5226 pr(Checks::Red, &["editorconfig"], 0),
5227 0,
5228 4,
5229 Duration::ZERO,
5230 Step::Fix {
5231 reason: "1 check(s) failing: editorconfig".to_owned(),
5232 },
5233 ),
5234 (
5235 "green with comments is fixed, not merged",
5236 pr(Checks::Green, &[], 2),
5237 1,
5238 4,
5239 Duration::ZERO,
5240 Step::Fix {
5241 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5242 .to_owned(),
5243 },
5244 ),
5245 (
5246 "green and clean merges",
5247 pr(Checks::Green, &[], 0),
5248 3,
5249 4,
5250 Duration::ZERO,
5251 Step::Merge,
5252 ),
5253 (
5254 "an unreadable rollup is waited on while the grace lasts",
5255 pr(Checks::Unknown, &[], 0),
5256 0,
5257 4,
5258 Duration::ZERO,
5259 Step::Wait,
5260 ),
5261 (
5262 "an unreadable rollup is never merged once the grace is spent",
5263 pr(Checks::Unknown, &[], 0),
5264 0,
5265 4,
5266 CHECKS_GRACE,
5267 Step::GiveUp {
5268 reason: "no check status is readable on the pull request after 3 minute(s); \
5269 refusing to merge on a guess"
5270 .to_owned(),
5271 },
5272 ),
5273 ];
5274 for (what, state, round, budget, waited, want) in cases {
5275 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5276 }
5277 }
5278
5279 #[test]
5280 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5281 let green = parse_pr(GREEN_OPEN).expect("parse");
5285 assert_eq!(green.blocking, Blocking::No);
5286 let red = parse_pr(RED_OPEN).expect("parse");
5287 assert_eq!(
5288 red.blocking,
5289 Blocking::No,
5290 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5291 );
5292 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5293 let quiet =
5295 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5296 assert_eq!(quiet.blocking, Blocking::Unsaid);
5297 }
5298
5299 #[test]
5300 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5301 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5307 nonblocking.blocking = Blocking::No;
5308 assert_eq!(
5309 decide(&nonblocking, 0, 4, Duration::ZERO),
5310 Step::Merge,
5311 "the forge says nothing is in the way, so nothing is"
5312 );
5313
5314 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5316 blocking.blocking = Blocking::Yes;
5317 assert!(matches!(
5318 decide(&blocking, 0, 4, Duration::ZERO),
5319 Step::Fix { .. }
5320 ));
5321
5322 let mut commented = pr(Checks::Red, &["coverage"], 1);
5325 commented.blocking = Blocking::No;
5326 assert!(matches!(
5327 decide(&commented, 0, 4, Duration::ZERO),
5328 Step::Fix { .. }
5329 ));
5330
5331 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5333 unsaid.blocking = Blocking::Unsaid;
5334 assert!(matches!(
5335 decide(&unsaid, 0, 4, Duration::ZERO),
5336 Step::Fix { .. }
5337 ));
5338 }
5339
5340 #[test]
5341 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5342 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5343 red.blocking = Blocking::No;
5344 assert_eq!(
5345 decide(&red, 0, 4, Duration::ZERO),
5346 Step::Merge,
5347 "announcing must not change the decision"
5348 );
5349 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5350 assert!(said.contains("yukimemi/magi"), "{said}");
5351 assert!(said.contains("#16"), "{said}");
5352 assert!(
5353 said.contains("https://github.com/yukimemi/magi/pull/16"),
5354 "{said}"
5355 );
5356 assert!(
5357 said.contains("test (windows-latest)") && said.contains("coverage"),
5358 "{said}"
5359 );
5360
5361 let green = pr(Checks::Green, &["stale"], 0);
5363 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5364 }
5365
5366 #[test]
5367 fn the_repo_label_comes_from_the_pull_request_url() {
5368 let p = Path::new("/tmp/checkout");
5369 assert_eq!(
5370 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5371 "yukimemi/magi"
5372 );
5373 assert_eq!(repo_label(p, "not a url"), "checkout");
5374 }
5375
5376 #[test]
5377 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5378 let mut conflicted = pr(Checks::Green, &[], 0);
5383 conflicted.blocking = Blocking::Conflict;
5384 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5385
5386 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5390 red.blocking = Blocking::Conflict;
5391 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5392
5393 let mut merged = pr(Checks::Red, &[], 0);
5395 merged.blocking = Blocking::Conflict;
5396 merged.state = PrLifecycle::Merged;
5397 assert_eq!(
5398 decide(&merged, 0, 4, Duration::ZERO),
5399 Step::Done { merged: true }
5400 );
5401 }
5402
5403 #[test]
5404 fn the_forge_verdict_is_read_off_merge_state_status() {
5405 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5408 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5409 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5410 }
5411 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5412 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5413 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5414 for quiet in ["", "UNKNOWN"] {
5417 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5418 assert!(Blocking::of(quiet).stops_a_merge());
5419 }
5420 }
5421
5422 #[test]
5423 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5424 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5425 let jj = "could not determine current branch: failed to run git: not on any branch";
5427
5428 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5429 .expect("the forge says merged, so it merged");
5430 assert!(landed.ok);
5431 assert!(
5432 landed.detail.contains("but the pull request is merged"),
5433 "the record must not read as a clean success: {}",
5434 landed.detail
5435 );
5436 assert!(
5437 landed.detail.contains("not on any branch"),
5438 "and it must keep what the command actually said: {}",
5439 landed.detail
5440 );
5441
5442 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5444 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5445 assert!(merged_after_all(&argv, jj, None).is_none());
5447 }
5448
5449 #[test]
5450 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5451 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5452 state.state = PrLifecycle::Closed;
5453 assert_eq!(
5454 decide(&state, 0, 4, Duration::ZERO),
5455 Step::Done { merged: false },
5456 "a human closing the pull request ends the loop, whatever CI says"
5457 );
5458 }
5459
5460 #[test]
5461 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5462 let red = decide(
5463 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5464 4,
5465 4,
5466 Duration::ZERO,
5467 );
5468 match red {
5469 Step::GiveUp { reason } => {
5470 assert!(reason.contains("editorconfig"), "reason: {reason}");
5471 assert!(reason.contains("test (macos)"), "reason: {reason}");
5472 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5473 }
5474 other => panic!("expected a give-up, got {other:?}"),
5475 }
5476
5477 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5478 match commented {
5479 Step::GiveUp { reason } => {
5480 assert!(reason.contains("unresolved"), "reason: {reason}");
5481 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5482 }
5483 other => panic!("expected a give-up, got {other:?}"),
5484 }
5485 }
5486
5487 #[test]
5488 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5489 let candidate_commit = "magi: candidate A (uncommitted work)";
5490 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5491 let argv = merge_argv(16, &subject);
5492
5493 assert!(argv.contains(&"--squash".to_owned()));
5494 assert!(argv.contains(&"--delete-branch".to_owned()));
5495 assert!(argv.contains(&"--subject".to_owned()));
5496 assert_eq!(
5497 argv.last().map(String::as_str),
5498 Some("add retries to the uploader"),
5499 "the subject must not be the candidate commit message"
5500 );
5501 assert_ne!(subject, candidate_commit);
5502 }
5503
5504 #[test]
5505 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5506 assert_eq!(
5507 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5508 "feat: a queue, an unattended loop, and a phone UI"
5509 );
5510 assert_eq!(
5511 merge_subject("", "# port the retry logic\n\ndetails"),
5512 "port the retry logic",
5513 "an empty title falls back to the task's first line, heading marks stripped"
5514 );
5515 }
5516
5517 #[test]
5518 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5519 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5520 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5521 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5522 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5523 assert_eq!(run_of(""), None);
5524 }
5525
5526 #[test]
5527 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5528 let mut out = Vec::new();
5529 push_if_outstanding(
5530 &mut out,
5531 ReviewComment {
5532 author: "yukimemi".to_owned(),
5533 path: None,
5534 line: None,
5535 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5536 },
5537 );
5538 assert!(out.is_empty());
5539 }
5540
5541 fn run_state() -> RunState {
5545 let mut state = RunState::new(
5546 std::path::PathBuf::from("/repo/magi"),
5547 "main".to_owned(),
5548 "abcdef1234".to_owned(),
5549 "add retries to the uploader".to_owned(),
5550 crate::config::Config::default(),
5551 );
5552 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5557 let nanos = std::time::SystemTime::now()
5558 .duration_since(std::time::UNIX_EPOCH)
5559 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5560 state.id = format!(
5561 "20261004-{nanos:06}-{:04x}",
5562 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5563 );
5564 state
5565 }
5566
5567 fn green_pr() -> PrState {
5568 PrState {
5569 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5570 number: 42,
5571 state: PrLifecycle::Open,
5572 checks: Checks::Green,
5573 blocking: Blocking::No,
5575 failing: Vec::new(),
5576 review_comments: vec![ReviewComment {
5577 author: "coderabbitai".to_owned(),
5578 path: Some("src/land.rs".to_owned()),
5579 line: Some(212),
5580 body: "this branch never checks the exit code".to_owned(),
5581 }],
5582 }
5583 }
5584
5585 #[test]
5586 fn github_facing_land_text_is_english_whatever_the_language() {
5587 let mut state = run_state();
5588 state.config.graph.language = "ja".to_owned();
5589 let comment = stop_comment(&state.id, "checks are still red");
5590 assert!(crate::github_text::check("", &comment).is_empty());
5591 assert!(comment.is_ascii(), "{comment}");
5592 assert!(comment.starts_with(MARKER));
5593
5594 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5595 let ja_at = p.find("Write all prose in ja").unwrap();
5596 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5597 assert!(ja_at < rule_at, "{p}");
5598 assert!(p.contains("stays in Japanese"), "{p}");
5599
5600 state.config.graph.language = "en".to_owned();
5601 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5602 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5603 assert!(!p.contains("does not apply"), "{p}");
5604 }
5605
5606 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5607
5608 fn panel() -> String {
5609 approval_panel(
5610 &run_state(),
5611 &green_pr(),
5612 NUMSTAT,
5613 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5614 &[
5615 "land: ask before merging".to_owned(),
5616 "land: colour the diff".to_owned(),
5617 ],
5618 "feat: merge approval from the phone",
5619 )
5620 }
5621
5622 #[test]
5623 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5624 let html = panel();
5625 for needle in [
5626 "42",
5627 "main",
5628 "src/land.rs",
5629 "src/web.rs",
5630 "assets/logo.png",
5631 "feat: merge approval from the phone",
5632 "land: ask before merging",
5633 "land: colour the diff",
5634 "coderabbitai",
5635 "this branch never checks the exit code",
5636 "green",
5637 ] {
5638 assert!(html.contains(needle), "the panel must state `{needle}`");
5639 }
5640 }
5641
5642 fn winning_candidate(summary: &str) -> Candidate {
5645 Candidate {
5646 index: 0,
5647 label: 'A',
5648 agent: "opus".to_owned(),
5649 branch: "magi/x/A".to_owned(),
5650 worktree: PathBuf::from("/wt/A"),
5651 summary: summary.to_owned(),
5652 stat: String::new(),
5653 files: 1,
5654 commits: 1,
5655 empty: false,
5656 failed: None,
5657 verified_noop: None,
5658 duration_ms: 0,
5659 folded: false,
5660 }
5661 }
5662
5663 fn uncontested_tally() -> Tally {
5664 Tally {
5665 first_choice: BTreeMap::from([('A', 1)]),
5666 borda: BTreeMap::new(),
5667 winner: 'A',
5668 rankings: 1,
5669 unanimous_initial: true,
5670 deliberated: false,
5671 changed_votes: 0,
5672 unanimous_final: true,
5673 tie_break: None,
5674 judges: 1,
5675 present: 1,
5676 quorum: 1,
5677 met_quorum: true,
5678 uncontested: None,
5679 }
5680 }
5681
5682 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5683 ReviewRecord {
5684 attempts: 0,
5685 reviewer,
5686 agent: agent.to_owned(),
5687 summary: summary.to_owned(),
5688 findings: Vec::new(),
5689 vote: None,
5690 failed: None,
5691 duration_ms: 0,
5692 }
5693 }
5694
5695 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5696 let answered = reviews.len();
5697 ReviewRound {
5698 round,
5699 head: "abc1234".to_owned(),
5700 verified_head: None,
5701 verified_at: None,
5702 reviews,
5703 e2e: Vec::new(),
5704 verify_retried: false,
5705 e2e_deferred: false,
5706 e2e_defer_reason: None,
5707 fix: None,
5708 blocking: 0,
5709 answered,
5710 expected: answered,
5711 clean: true,
5712 progressed: false,
5713 vote_split: false,
5714 reconsideration: Vec::new(),
5715 verdict: None,
5716 }
5717 }
5718
5719 #[test]
5720 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5721 let en = panel();
5722 assert!(en.contains("Task"), "{en}");
5723 assert!(en.contains("add retries to the uploader"), "{en}");
5724
5725 let mut state = run_state();
5726 state.config.graph.language = "ja".to_owned();
5727 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5728 assert!(ja.contains("タスク"), "{ja}");
5729 assert!(
5730 ja.contains("add retries to the uploader"),
5731 "the task itself is not translated: {ja}"
5732 );
5733 }
5734
5735 #[test]
5736 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5737 let html = panel();
5741 assert!(!html.contains("What changed"), "{html}");
5742 assert!(!html.contains("Review verdict"), "{html}");
5743 }
5744
5745 #[test]
5746 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5747 let mut state = run_state();
5748 state.candidates = vec![winning_candidate("")];
5749 state.tally = Some(uncontested_tally());
5750 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5751 assert!(
5752 !html.contains("What changed"),
5753 "an empty summary must not render an empty box: {html}"
5754 );
5755 }
5756
5757 #[test]
5758 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5759 let mut state = run_state();
5760 state.candidates = vec![winning_candidate(
5761 "Added a retry loop around the uploader PUT call.",
5762 )];
5763 state.tally = Some(uncontested_tally());
5764 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5765 assert!(en.contains("What changed"), "{en}");
5766 assert!(
5767 en.contains("Added a retry loop around the uploader PUT call."),
5768 "{en}"
5769 );
5770
5771 state.config.graph.language = "ja".to_owned();
5772 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5773 assert!(ja.contains("変更内容"), "{ja}");
5774 assert!(
5775 ja.contains("Added a retry loop around the uploader PUT call."),
5776 "{ja}"
5777 );
5778 }
5779
5780 #[test]
5781 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5782 let mut state = run_state();
5783 state.reviews = vec![
5784 review_round(
5785 1,
5786 vec![review_record(1, "alpha", "found a race, sent back")],
5787 ),
5788 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5789 ];
5790 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5791 assert!(en.contains("Review verdict"), "{en}");
5792 assert!(en.contains("race is fixed, clean"), "{en}");
5793 assert!(
5794 !en.contains("found a race, sent back"),
5795 "only the round that actually cleared the merge should show: {en}"
5796 );
5797
5798 state.config.graph.language = "ja".to_owned();
5799 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5800 assert!(ja.contains("レビューの結論"), "{ja}");
5801 assert!(ja.contains("レビュアー"), "{ja}");
5802 assert!(ja.contains("race is fixed, clean"), "{ja}");
5803 }
5804
5805 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5811 ReviewRecord {
5812 attempts: 0,
5813 reviewer,
5814 agent: agent.to_owned(),
5815 summary: String::new(),
5816 findings: Vec::new(),
5817 vote: None,
5818 failed: Some(reason.to_owned()),
5819 duration_ms: 0,
5820 }
5821 }
5822
5823 #[test]
5824 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5825 let mut state = run_state();
5826 state.reviews = vec![review_round(
5827 1,
5828 vec![
5829 review_record(1, "alpha", "clean, nothing to add"),
5830 unanswered_review_record(2, "beta", "timed out"),
5831 ],
5832 )];
5833 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5834 assert!(en.contains("clean, nothing to add"), "{en}");
5835 assert!(
5836 en.contains("produced no answer: timed out"),
5837 "a seat that never answered must say so, not render a blank box: {en}"
5838 );
5839 assert!(
5840 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5841 "no reviewer box may be left empty: {en}"
5842 );
5843
5844 state.config.graph.language = "ja".to_owned();
5845 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5846 assert!(ja.contains("回答なし: timed out"), "{ja}");
5847 }
5848
5849 #[test]
5850 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5851 let html = panel();
5852 assert!(!html.contains("<script"), "no script survives the csp");
5853 assert!(!html.contains("<form"), "form-action is 'none'");
5854 let pr = green_pr();
5855 assert_eq!(
5856 html.matches("http").count(),
5857 html.matches(pr.url.as_str()).count(),
5858 "the only http url in the panel is the pull request's own link"
5859 );
5860 assert!(
5861 html.contains("target=\"_blank\""),
5862 "the PR link must open in a new tab so it escapes the iframe"
5863 );
5864 assert!(
5865 html.contains("rel=\"noopener noreferrer\""),
5866 "external links opening in new tabs must carry noopener noreferrer"
5867 );
5868 }
5869
5870 #[test]
5871 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5872 let html = panel();
5873 assert!(
5874 html.contains(">+</span>"),
5875 "an added line carries a `+` in the gutter, not only a background"
5876 );
5877 assert!(
5878 html.contains(">-</span>"),
5879 "a removed line carries a `-` in the gutter, not only a background"
5880 );
5881 assert!(
5882 html.contains(">new line</span>"),
5883 "the marker is moved to the gutter, so the body is printed once without it"
5884 );
5885 }
5886
5887 #[test]
5888 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5889 let total = DIFF_MAX_LINES + 100;
5890 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5891 let html = approval_panel(
5892 &run_state(),
5893 &green_pr(),
5894 NUMSTAT,
5895 &diff,
5896 &[],
5897 "feat: something long",
5898 );
5899 assert!(
5900 html.contains(&format!("100 of {total} diff lines omitted")),
5901 "the note must say exactly how much was cut"
5902 );
5903 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5904 assert!(
5905 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5906 "nothing past the threshold is rendered"
5907 );
5908 assert!(
5909 html.contains("/repo/magi"),
5910 "the note says where the rest is"
5911 );
5912 }
5913
5914 #[test]
5915 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5916 let html = approval_panel(
5917 &run_state(),
5918 &green_pr(),
5919 "1\t2\tsrc/<b>&\"x\"'.rs",
5920 "",
5921 &[],
5922 "subject",
5923 );
5924 assert!(html.contains("src/<b>&"x"'.rs"));
5925 assert!(
5926 !html.contains("<b>"),
5927 "an agent-influenced path must never become markup"
5928 );
5929 }
5930
5931 #[tokio::test]
5932 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5933 let a = std::path::PathBuf::from("/repo/a");
5934 let b = std::path::PathBuf::from("/repo/b");
5935
5936 let held = repo_merge_lock(&a).lock_owned().await;
5937
5938 assert!(
5941 repo_merge_lock(&a).try_lock().is_err(),
5942 "a second merge into the same repository must not proceed concurrently"
5943 );
5944
5945 assert!(
5949 repo_merge_lock(&b).try_lock().is_ok(),
5950 "a different repository's merge lock must be independent"
5951 );
5952
5953 drop(held);
5954 assert!(
5955 repo_merge_lock(&a).try_lock().is_ok(),
5956 "the lock is released once the holder is done"
5957 );
5958 }
5959
5960 #[test]
5961 fn only_the_merge_choice_merges_and_silence_holds() {
5962 let table = [
5963 (None, Approval::Hold),
5964 (Some("merge"), Approval::Merge),
5965 (Some(" merge\n"), Approval::Merge),
5966 (Some("hold"), Approval::Hold),
5967 (Some(""), Approval::Hold),
5968 (Some("yes"), Approval::Hold),
5969 ];
5970 for (answer, want) in table {
5971 assert_eq!(
5972 approval(answer),
5973 want,
5974 "answer {answer:?} must resolve to {want:?}"
5975 );
5976 }
5977 }
5978
5979 #[tokio::test]
5980 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5981 let mut state = landing_state();
5982 state.config.graph.land_approval = true;
5983 let pr = green_pr();
5984
5985 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5986 .await
5987 .unwrap();
5988 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5989 assert!(
5990 !state.parked,
5991 "approval_gate itself never sets `parked`; only its caller does"
5992 );
5993
5994 let store = ask::Questions::open();
5995 let filed: Vec<_> = store
5996 .list()
5997 .into_iter()
5998 .filter(|q| q.run == state.id)
5999 .collect();
6000 assert_eq!(filed.len(), 1, "exactly one question is filed");
6001 assert_eq!(filed[0].node, APPROVAL_NODE);
6002 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
6003 assert!(filed[0].status.open());
6004
6005 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6009 .await
6010 .unwrap();
6011 assert_eq!(again, ApprovalGate::Pending);
6012 let still_one = store
6013 .list()
6014 .into_iter()
6015 .filter(|q| q.run == state.id)
6016 .count();
6017 assert_eq!(
6018 still_one, 1,
6019 "asking twice must not double-file the question"
6020 );
6021 }
6022
6023 #[tokio::test]
6024 async fn approving_the_existing_question_is_read_back_as_approved() {
6025 crate::run::pin_test_home();
6026 let mut state = run_state();
6027 state.config.graph.land_approval = true;
6028 let pr = green_pr();
6029 assert_eq!(
6030 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6031 .await
6032 .unwrap(),
6033 ApprovalGate::Pending
6034 );
6035
6036 let store = ask::Questions::open();
6037 let mut q = store
6038 .list()
6039 .into_iter()
6040 .find(|q| q.run == state.id)
6041 .expect("filed above");
6042 let talks = crate::talk::Talks::open();
6043 let cfg = crate::config::Config {
6044 agents: vec![crate::config::AgentSpec {
6045 id: "mock".into(),
6046 kind: crate::config::AgentKind::Command,
6047 model: None,
6048 command: vec!["true".into()],
6049 extra_args: Vec::new(),
6050 env: Default::default(),
6051 prompt_delivery: None,
6052 }],
6053 ..crate::config::Config::default()
6054 };
6055 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6056 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6057 crate::talk::drain(&mut talk, &talks).unwrap();
6058 assert_eq!(
6059 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6060 .await
6061 .unwrap(),
6062 ApprovalGate::Pending,
6063 "consultation cannot approve or hold the merge"
6064 );
6065 q = store.get(&q.id).unwrap();
6066 assert!(q.answer.is_none());
6067 talk.turns.push(crate::talk::Turn {
6068 breaks: Some(Vec::new()),
6069 messages: Vec::new(),
6070 posted_by: None,
6071 who: crate::talk::Who::Operator,
6072 body: "Merge this pull request now".into(),
6073 at: Timestamp::now(),
6074 attachments: Vec::new(),
6075 usage: None,
6076 });
6077 talks.put(&mut talk).unwrap();
6078 crate::consult::validate_answer(
6079 &q,
6080 &talks,
6081 &talk.id,
6082 crate::queue::CHAT_NODE,
6083 APPROVE,
6084 Some("Merge this pull request now"),
6085 )
6086 .unwrap();
6087 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6088 store.put(&mut q).unwrap();
6089
6090 assert_eq!(
6091 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6092 .await
6093 .unwrap(),
6094 ApprovalGate::Approved
6095 );
6096 }
6097
6098 #[tokio::test]
6099 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6100 crate::run::pin_test_home();
6101 let store = ask::Questions::open();
6102
6103 let mut held_state = run_state();
6104 held_state.config.graph.land_approval = true;
6105 let pr = green_pr();
6106 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6107 .await
6108 .unwrap();
6109 let mut q = store
6110 .list()
6111 .into_iter()
6112 .find(|q| q.run == held_state.id)
6113 .expect("filed above");
6114 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6115 store.put(&mut q).unwrap();
6116 assert_eq!(
6117 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6118 .await
6119 .unwrap(),
6120 ApprovalGate::Held
6121 );
6122
6123 let mut abandoned_state = run_state();
6124 abandoned_state.config.graph.land_approval = true;
6125 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6126 .await
6127 .unwrap();
6128 let mut q = store
6129 .list()
6130 .into_iter()
6131 .find(|q| q.run == abandoned_state.id)
6132 .expect("filed above");
6133 q.abandon("no answer within the timeout");
6134 store.put(&mut q).unwrap();
6135 assert_eq!(
6136 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6137 .await
6138 .unwrap(),
6139 ApprovalGate::Held,
6140 "silence must never merge"
6141 );
6142 }
6143
6144 fn contested() -> ContestedHandoff {
6145 let finding = |id: &str, n: u32| crate::verdict::Finding {
6146 id: id.to_owned(),
6147 severity: crate::verdict::Severity::Major,
6148 file: Some("src/a.rs".to_owned()),
6149 line: Some(n),
6150 title: format!("problem {id}"),
6151 detail: String::new(),
6152 };
6153 ContestedHandoff {
6154 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6155 rejecters: vec![(1, "alpha".to_owned())],
6156 }
6157 }
6158
6159 #[test]
6160 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6161 let mut state = run_state();
6162 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6163 state.contested_handoff = Some(contested());
6164 assert!(contested_to_ask(&state).is_some());
6165 state.config.graph.hold_contested_merge = false;
6166 assert!(
6167 contested_to_ask(&state).is_none(),
6168 "the switch restores today"
6169 );
6170 }
6171
6172 #[test]
6173 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6174 let q = ask::Question::new(
6175 "run-1".to_owned(),
6176 APPROVAL_NODE.to_owned(),
6177 "land".to_owned(),
6178 "Merge?".to_owned(),
6179 String::new(),
6180 vec![APPROVE.to_owned(), HOLD.to_owned()],
6181 );
6182 let none = deputy_brief(&q, None);
6183 assert!(none.contains("could not be read"), "{none}");
6184 assert!(none.contains("Silence is a hold"), "{none}");
6185
6186 let mut state = run_state();
6187 state.pr = Some(crate::run::PrRecord {
6188 url: "https://example.test/pull/7".to_owned(),
6189 number: 7,
6190 state: "open".to_owned(),
6191 checks: "green".to_owned(),
6192 round: 0,
6193 rounds: 3,
6194 red_at_merge: Vec::new(),
6195 });
6196 state.contested_handoff = Some(contested());
6197 let b = deputy_brief(&q, Some(&state));
6198 assert!(b.contains("https://example.test/pull/7"), "{b}");
6199 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6200 assert!(b.contains("#1"), "the rejecting seat: {b}");
6201 state.contested_handoff = None;
6202 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6203 }
6204
6205 #[test]
6206 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6207 for lang in ["en", "ja"] {
6208 let mut cfg = crate::config::Config::default();
6209 cfg.graph.language = lang.to_owned();
6210 let w = words(&cfg.graph.language);
6211 let text = w.approval_detail(
6212 "https://github.com/yukimemi/magi/pull/42",
6213 "main",
6214 "feat: x",
6215 Some(&contested()),
6216 );
6217 assert!(text.contains("pull/42"), "{text}");
6218 assert!(
6219 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6220 "{text}"
6221 );
6222 assert!(text.contains("R3-1-5"), "{text}");
6223 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6224 assert!(text.contains("2"), "the rest are counted: {text}");
6225 assert!(text.contains("#1 (alpha)"), "{text}");
6226 }
6227 let plain = words("en").approval_detail("u", "main", "s", None);
6228 assert!(!plain.contains("reject"), "{plain}");
6229 }
6230
6231 #[tokio::test]
6232 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6233 crate::run::pin_test_home();
6234 let mut state = run_state();
6235 state.config.graph.land_approval = false;
6236 state.contested_handoff = Some(contested());
6237 let pr = green_pr();
6238 let c = contested_to_ask(&state);
6239 assert_eq!(
6240 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6241 .await
6242 .unwrap(),
6243 ApprovalGate::Pending,
6244 "silence is a hold"
6245 );
6246 let store = ask::Questions::open();
6247 let filed: Vec<_> = store
6248 .list()
6249 .into_iter()
6250 .filter(|q| q.run == state.id)
6251 .collect();
6252 assert_eq!(filed.len(), 1);
6253 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6254
6255 assert_eq!(
6256 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6257 .await
6258 .unwrap(),
6259 ApprovalGate::Pending
6260 );
6261 let mut q = store
6262 .list()
6263 .into_iter()
6264 .find(|q| q.run == state.id)
6265 .unwrap();
6266 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6267 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6268 store.put(&mut q).unwrap();
6269 assert_eq!(
6270 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6271 .await
6272 .unwrap(),
6273 ApprovalGate::Approved
6274 );
6275 }
6276
6277 #[test]
6278 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6279 let rows = parse_numstat(NUMSTAT);
6280 assert_eq!(
6281 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6282 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6283 );
6284 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6285 }
6286 #[test]
6287 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6288 let mut state = run_state();
6292 state.config.graph.language = "ja".to_owned();
6293 let pr = green_pr();
6294 let commits = ["c1".to_owned()];
6295
6296 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6297 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6298 assert!(ja.contains("squash されるコミット"), "{ja}");
6299 assert!(ja.contains("レビューコメント"), "{ja}");
6300 assert!(ja.contains("差分"), "{ja}");
6301 assert!(
6302 !ja.contains("Commits being squashed"),
6303 "no English left over"
6304 );
6305
6306 let w = words("ja");
6307 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6308 assert!(
6309 w.approval_detail("http://x/1", "main", "feat: x", None)
6310 .contains("パネル")
6311 );
6312
6313 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6315 assert!(ja.contains("feat: x"), "nor is the merge subject");
6316
6317 state.config.graph.language = "en".to_owned();
6320 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6321 assert!(en.contains("Commits being squashed"), "{en}");
6322 assert_eq!(words("Klingon").html_lang, "en");
6323 }
6324
6325 #[test]
6329 fn pick_open_pr_classifies_by_count_and_base() {
6330 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6331 assert_eq!(
6332 pick_open_pr(one, "main").unwrap(),
6333 OpenPr::One {
6334 url: "https://x/pull/58".into(),
6335 title: "t".into()
6336 }
6337 );
6338 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6339 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6340 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6341 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6342 assert_eq!(
6343 pick_open_pr(two, "main").unwrap(),
6344 OpenPr::Many(vec!["u1".into(), "u2".into()])
6345 );
6346 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6348 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6349 assert!(pick_open_pr("not json", "main").is_err());
6350 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6352 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6353 }
6354
6355 #[test]
6356 fn pick_merged_pr_picks_the_unique_match() {
6357 let json = r#"[
6358 {"url": "https://github.com/o/r/pull/42", "number": 42,
6359 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6360 ]"#;
6361 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6362 let found = pick_merged_pr(json, "main", created_at)
6363 .expect("valid json")
6364 .expect("one unambiguous match");
6365 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6366 assert_eq!(found.number, 42);
6367 }
6368
6369 #[test]
6373 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6374 let json = r#"[
6375 {"url": "https://github.com/o/r/pull/42", "number": 42,
6376 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6377 {"url": "https://github.com/o/r/pull/43", "number": 43,
6378 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6379 ]"#;
6380 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6381 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6382 }
6383
6384 #[test]
6388 fn pick_merged_pr_ignores_a_different_base_branch() {
6389 let json = r#"[
6390 {"url": "https://github.com/o/r/pull/42", "number": 42,
6391 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6392 ]"#;
6393 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6394 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6395 }
6396
6397 #[test]
6400 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6401 let json = r#"[
6402 {"url": "https://github.com/o/r/pull/42", "number": 42,
6403 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6404 ]"#;
6405 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6406 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6407 }
6408
6409 #[test]
6410 fn slug_of_pr_url_reads_host_owner_and_repo() {
6411 assert_eq!(
6412 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6413 Some("github.com/yukimemi/shun")
6414 );
6415 }
6416
6417 #[test]
6418 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6419 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6420 assert_eq!(slug_of_pr_url("not a url at all"), None);
6421 assert_eq!(slug_of_pr_url("https://github.com"), None);
6422 }
6423
6424 #[test]
6425 fn slug_of_repo_url_reads_host_owner_and_repo() {
6426 assert_eq!(
6427 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6428 Some("github.com/yukimemi/magi")
6429 );
6430 assert_eq!(slug_of_repo_url("https://github.com"), None);
6431 }
6432
6433 #[test]
6434 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6435 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6436 .expect("same repo, different case");
6437 }
6438
6439 #[test]
6444 fn ensure_same_repo_refuses_a_different_repo() {
6445 let err =
6446 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6447 let msg = format!("{err:#}");
6448 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6449 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6450 }
6451
6452 #[test]
6457 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6458 let err = ensure_same_repo(
6459 "github.com/yukimemi/magi",
6460 "github.example.com/yukimemi/magi",
6461 )
6462 .unwrap_err();
6463 let msg = format!("{err:#}");
6464 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6465 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6466 }
6467
6468 #[tokio::test]
6473 async fn find_external_merge_returns_none_without_a_winner() {
6474 let state = RunState::new(
6475 PathBuf::from("/no/such/repo"),
6476 "main".to_owned(),
6477 "0000000000000000000000000000000000000000".to_owned(),
6478 "irrelevant".to_owned(),
6479 crate::config::Config::default(),
6480 );
6481 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6482 }
6483
6484 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6485 let mut run = RunState::new(
6486 PathBuf::from(repo),
6487 "main".to_owned(),
6488 "abcdef1234".to_owned(),
6489 "x".to_owned(),
6490 crate::config::Config::default(),
6491 );
6492 run.id = id.to_owned();
6493 run.status = status;
6494 run.pr = Some(crate::run::PrRecord {
6495 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6496 url: url.to_owned(),
6497 state: state.to_owned(),
6498 checks: "red".to_owned(),
6499 round: 0,
6500 rounds: 2,
6501 red_at_merge: Vec::new(),
6502 });
6503 run.save_under(home).unwrap();
6504 }
6505
6506 fn recorded(home: &Path, id: &str) -> String {
6507 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6508 serde_json::from_str::<RunState>(&body)
6509 .unwrap()
6510 .pr
6511 .unwrap()
6512 .state
6513 }
6514
6515 const PR: &str = "https://github.com/o/r/pull/7";
6516
6517 #[test]
6518 fn write_through_updates_predecessors_and_siblings_only() {
6519 let tmp = tempfile::tempdir().unwrap();
6520 let h = tmp.path();
6521 pr_run(
6522 h,
6523 "20261004-100000-aaaa",
6524 "/repo/r",
6525 RunStatus::Superseded,
6526 PR,
6527 "open",
6528 );
6529 pr_run(
6530 h,
6531 "20261004-100100-bbbb",
6532 "/repo/r",
6533 RunStatus::Blocked,
6534 PR,
6535 "open",
6536 );
6537 pr_run(
6539 h,
6540 "20261004-100200-cccc",
6541 "/repo/r",
6542 RunStatus::Landing,
6543 PR,
6544 "open",
6545 );
6546 pr_run(
6548 h,
6549 "20261004-100300-dddd",
6550 "/repo/other",
6551 RunStatus::Blocked,
6552 "https://github.com/o/other/pull/7",
6553 "open",
6554 );
6555 pr_run(
6557 h,
6558 "20261004-100400-eeee",
6559 "/repo/r",
6560 RunStatus::Blocked,
6561 "https://github.com/o/r/pull/8",
6562 "open",
6563 );
6564 pr_run(
6565 h,
6566 "20261004-100500-ffff",
6567 "/repo/r",
6568 RunStatus::Merged,
6569 PR,
6570 "open",
6571 );
6572 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6573
6574 assert_eq!(
6575 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6576 2
6577 );
6578 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6579 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6580 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6581 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6582 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6583 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6585 assert_eq!(
6587 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6588 0
6589 );
6590 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6591 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6592 }
6593
6594 #[test]
6595 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6596 let tmp = tempfile::tempdir().unwrap();
6597 let h = tmp.path();
6598 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6599 pr_run(
6600 h,
6601 "20261004-100000-aaaa",
6602 "/repo/r",
6603 RunStatus::Superseded,
6604 &url(1),
6605 "open",
6606 );
6607 pr_run(
6608 h,
6609 "20261004-100100-bbbb",
6610 "/repo/r",
6611 RunStatus::Blocked,
6612 &url(2),
6613 "open",
6614 );
6615 pr_run(
6616 h,
6617 "20261004-100200-cccc",
6618 "/repo/r",
6619 RunStatus::Ready,
6620 &url(3),
6621 "open",
6622 );
6623 pr_run(
6624 h,
6625 "20261004-100300-dddd",
6626 "/repo/r",
6627 RunStatus::Ready,
6628 &url(4),
6629 "open",
6630 );
6631 pr_run(
6632 h,
6633 "20261004-100400-eeee",
6634 "/repo/r",
6635 RunStatus::Implementing,
6636 &url(1),
6637 "open",
6638 );
6639 assert_eq!(stale_open_prs(h).len(), 4);
6640
6641 let mut known = BTreeMap::new();
6642 known.insert(url(1), PrLifecycle::Merged);
6643 known.insert(url(2), PrLifecycle::Closed);
6644 known.insert(url(3), PrLifecycle::Open);
6645 assert_eq!(apply_pr_states(h, &known), 2);
6647 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6648 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6649 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6650 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6651 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6652 assert_eq!(apply_pr_states(h, &known), 0);
6653 }
6654
6655 use std::collections::VecDeque;
6658 use std::sync::Mutex;
6659
6660 struct Scripted {
6663 views: Mutex<VecDeque<Seen>>,
6664 merges: Mutex<VecDeque<(bool, String)>>,
6665 fix: Mutex<Option<Fixed>>,
6666 log: Mutex<Vec<&'static str>>,
6667 argvs: Mutex<Vec<Vec<String>>>,
6668 required: Mutex<Option<BTreeSet<String>>>,
6669 merged: Mutex<bool>,
6672 queued: Mutex<bool>,
6673 unreadable_after_merge: Mutex<bool>,
6675 reruns: Mutex<Vec<String>>,
6677 rerun_fails: Mutex<bool>,
6678 clock: Mutex<i64>,
6680 }
6681
6682 impl Scripted {
6683 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6684 Self {
6685 views: Mutex::new(views.into()),
6686 merges: Mutex::new(
6687 merges
6688 .into_iter()
6689 .map(|(ok, m)| (ok, m.to_owned()))
6690 .collect(),
6691 ),
6692 fix: Mutex::new(None),
6693 log: Mutex::new(Vec::new()),
6694 argvs: Mutex::new(Vec::new()),
6695 required: Mutex::new(None),
6696 merged: Mutex::new(false),
6697 queued: Mutex::new(false),
6698 unreadable_after_merge: Mutex::new(false),
6699 reruns: Mutex::new(Vec::new()),
6700 rerun_fails: Mutex::new(false),
6701 clock: Mutex::new(1_000),
6702 }
6703 }
6704 fn argvs(&self) -> Vec<Vec<String>> {
6705 self.argvs.lock().unwrap().clone()
6706 }
6707 fn calls(&self) -> Vec<&'static str> {
6708 self.log.lock().unwrap().clone()
6709 }
6710 }
6711
6712 impl Forge for Scripted {
6713 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6714 self.log.lock().unwrap().push("view");
6715 if *self.unreadable_after_merge.lock().unwrap()
6716 && !self.argvs.lock().unwrap().is_empty()
6717 {
6718 anyhow::bail!("forge unreachable");
6719 }
6720 let mut v = self.views.lock().unwrap();
6721 let mut seen = if v.len() > 1 {
6722 v.pop_front().unwrap()
6723 } else {
6724 v[0].clone()
6725 };
6726 if *self.merged.lock().unwrap() {
6727 seen.pr.state = PrLifecycle::Merged;
6728 }
6729 Ok(seen)
6730 }
6731 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6732 self.log.lock().unwrap().push("merge");
6733 self.argvs.lock().unwrap().push(argv.to_vec());
6734 let out = self
6735 .merges
6736 .lock()
6737 .unwrap()
6738 .pop_front()
6739 .expect("unscripted merge");
6740 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6741 {
6742 *self.merged.lock().unwrap() = true;
6743 }
6744 Ok(out)
6745 }
6746 async fn poll(&self) {
6747 self.log.lock().unwrap().push("poll");
6748 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
6749 }
6750 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
6751 self.log.lock().unwrap().push("rerun");
6752 self.reruns.lock().unwrap().push(run.to_owned());
6753 if *self.rerun_fails.lock().unwrap() {
6754 anyhow::bail!("gh run rerun: no");
6755 }
6756 Ok(())
6757 }
6758 fn now(&self) -> i64 {
6759 *self.clock.lock().unwrap()
6760 }
6761 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6762 self.required.lock().unwrap().clone()
6763 }
6764 async fn fix(
6765 &self,
6766 _state: &mut RunState,
6767 _pr: &PrState,
6768 _round: usize,
6769 _budget: usize,
6770 _reason: &str,
6771 _logs: &str,
6772 ) -> Result<Fixed> {
6773 self.log.lock().unwrap().push("fix");
6774 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6775 }
6776 }
6777
6778 const REFUSED: &str =
6779 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6780
6781 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6782 let mut pr = green_pr();
6783 pr.checks = checks;
6784 pr.blocking = Blocking::of(merge_state);
6785 if !comments {
6786 pr.review_comments.clear();
6787 }
6788 Seen {
6789 pr,
6790 title: "feat: x".to_owned(),
6791 failing_urls: Vec::new(),
6792 head: head.to_owned(),
6793 rollup_head: head.to_owned(),
6794 merge_state: merge_state.to_owned(),
6795 contexts: Vec::new(),
6796 base: "main".to_owned(),
6797 }
6798 }
6799
6800 fn landing_state() -> RunState {
6801 crate::run::pin_test_home();
6802 let mut state = run_state();
6803 state.config.graph.land_approval = false;
6804 state
6805 }
6806
6807 #[test]
6808 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6809 assert!(!awaiting_new_head(None, "aaa"));
6810 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6811 assert!(awaiting_new_head(Some("abc123"), "def456"));
6812 assert!(awaiting_new_head(Some("abc123"), ""));
6813 }
6814
6815 #[test]
6816 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6817 let open = |c, m: &str| seen("a", c, m, false);
6818 let table = [
6819 (None, false, Refused::Pending),
6820 (
6821 Some(open(Checks::Pending, "BLOCKED")),
6822 false,
6823 Refused::Pending,
6824 ),
6825 (
6826 Some(open(Checks::Unknown, "BLOCKED")),
6827 false,
6828 Refused::Pending,
6829 ),
6830 (
6831 Some(open(Checks::Green, "UNKNOWN")),
6832 false,
6833 Refused::Pending,
6834 ),
6835 (Some(open(Checks::Green, "")), false, Refused::Pending),
6836 (
6837 Some(open(Checks::Green, "BLOCKED")),
6838 false,
6839 Refused::Recheck,
6840 ),
6841 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6842 ];
6843 for (after, rechecked, want) in table {
6844 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6845 }
6846 let mut closed = open(Checks::Green, "CLEAN");
6847 closed.pr.state = PrLifecycle::Closed;
6848 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6849 }
6850
6851 #[tokio::test]
6852 async fn a_normal_landing_merges_on_the_first_look() {
6853 let mut state = landing_state();
6854 let forge = Scripted::new(
6855 vec![seen("a", Checks::Green, "CLEAN", false)],
6856 vec![(true, "")],
6857 );
6858 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6859 .await
6860 .unwrap();
6861 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6863 assert_eq!(state.status, RunStatus::Merged);
6864 }
6865
6866 #[tokio::test]
6867 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6868 let mut state = landing_state();
6869 let forge = Scripted::new(
6870 vec![seen("a", Checks::Green, "CLEAN", false)],
6871 std::iter::repeat_n((true, ""), 100).collect(),
6872 );
6873 *forge.queued.lock().unwrap() = true;
6874 let task = async {
6875 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6876 .await
6877 .unwrap();
6878 };
6879 task.await;
6882 assert_ne!(state.status, RunStatus::Merged);
6883 }
6884
6885 #[tokio::test]
6886 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6887 let mut state = landing_state();
6888 let forge = Scripted::new(
6889 vec![seen("a", Checks::Green, "CLEAN", false)],
6890 std::iter::repeat_n((true, ""), 100).collect(),
6891 );
6892 *forge.unreadable_after_merge.lock().unwrap() = true;
6893 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6894 .await
6895 .ok();
6896 assert_ne!(state.status, RunStatus::Merged);
6897 }
6898
6899 #[tokio::test]
6900 async fn a_task_public_head_is_awaited_on_land_entry() {
6901 let mut state = landing_state();
6902 state.pr_head = Some("magi/task-abcd-1234".into());
6903 state.pr_pushed_head = Some("new".into());
6904 let forge = Scripted::new(
6905 vec![
6906 seen("old", Checks::Green, "CLEAN", false),
6907 seen("new", Checks::Green, "CLEAN", false),
6908 ],
6909 vec![(true, "")],
6910 );
6911 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6912 .await
6913 .unwrap();
6914 assert_eq!(
6915 forge.calls(),
6916 ["view", "poll", "view", "view", "merge", "view"]
6917 );
6918 assert_eq!(state.status, RunStatus::Merged);
6919 }
6920
6921 #[tokio::test]
6922 async fn a_superseded_public_head_owner_cannot_reenter_land() {
6923 let mut state = landing_state();
6924 state.pr_taken_over_by = Some("next".into());
6925 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
6926 assert!(
6927 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6928 .await
6929 .is_err()
6930 );
6931 assert!(forge.calls().is_empty());
6932 }
6933
6934 #[tokio::test]
6935 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6936 let mut state = landing_state();
6937 let forge = Scripted::new(
6938 vec![
6939 seen("old", Checks::Green, "CLEAN", true),
6940 seen("old", Checks::Green, "CLEAN", true),
6942 seen("new", Checks::Pending, "BLOCKED", true),
6943 seen("new", Checks::Green, "CLEAN", true),
6944 ],
6945 vec![(true, "")],
6946 );
6947 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6948 head: "NEW".to_owned(),
6949 });
6950 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6951 .await
6952 .unwrap();
6953 assert_eq!(
6954 forge.calls(),
6955 [
6956 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6957 "view"
6958 ]
6959 );
6960 assert_eq!(state.status, RunStatus::Merged);
6961 }
6962
6963 #[tokio::test]
6964 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6965 let mut state = landing_state();
6966 let forge = Scripted::new(
6967 vec![
6968 seen("old", Checks::Green, "CLEAN", true),
6969 seen("someone-elses", Checks::Green, "CLEAN", true),
6970 ],
6971 vec![],
6972 );
6973 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6974 head: "mine".to_owned(),
6975 });
6976 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6977 .await
6978 .unwrap();
6979 assert!(!forge.calls().contains(&"merge"));
6980 let why = state.merge.as_ref().unwrap().detail.clone();
6981 assert!(
6982 why.contains("mine") && why.contains("someone-elses"),
6983 "{why}"
6984 );
6985 assert_eq!(state.status, RunStatus::Blocked);
6986 }
6987
6988 #[tokio::test]
6989 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6990 let mut state = landing_state();
6991 let forge = Scripted::new(
6992 vec![
6993 seen("a", Checks::Green, "CLEAN", false),
6994 seen("a", Checks::Green, "CLEAN", false),
6995 seen("a", Checks::Pending, "BLOCKED", false),
6996 seen("a", Checks::Pending, "BLOCKED", false),
6997 seen("a", Checks::Green, "CLEAN", false),
6998 ],
6999 vec![(false, REFUSED), (true, "")],
7000 );
7001 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7002 .await
7003 .unwrap();
7004 assert_eq!(
7005 forge.calls(),
7006 [
7007 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7008 "view"
7009 ]
7010 );
7011 assert_eq!(state.status, RunStatus::Merged);
7012 }
7013
7014 #[tokio::test]
7015 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7016 let mut state = landing_state();
7017 let forge = Scripted::new(
7018 vec![
7019 seen("a", Checks::Green, "CLEAN", false),
7020 seen("a", Checks::Green, "BLOCKED", false),
7021 ],
7022 vec![(false, REFUSED), (false, REFUSED)],
7023 );
7024 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7025 .await
7026 .unwrap();
7027 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7029 let why = state.merge.as_ref().unwrap().detail.clone();
7030 assert!(
7031 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7032 "{why}"
7033 );
7034 assert_eq!(state.status, RunStatus::Blocked);
7035 }
7036
7037 #[test]
7038 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7039 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7040 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7041 assert_eq!(bound_head("old", "old", Some("new")), None);
7043 assert_eq!(bound_head("new", "old", Some("new")), None);
7045 assert_eq!(bound_head("new", "old", None), None);
7046 assert_eq!(bound_head("", "", None), None);
7048 assert_eq!(bound_head("", "", Some("new")), None);
7049 assert_eq!(bound_head("abc", "", None), None);
7050 }
7051
7052 fn view_json(head: &str) -> String {
7053 format!(
7054 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7055 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7056 "reviews":[],"comments":[]}}"#
7057 )
7058 }
7059
7060 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7061 format!(
7062 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7063 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7064 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7065 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7066 )
7067 }
7068
7069 #[test]
7070 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7071 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7074 assert_eq!(s.rollup_head, "old");
7075 assert_eq!(s.pr.checks, Checks::Red);
7076 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7077 assert_eq!(s.failing_urls.len(), 1);
7078 }
7079
7080 #[test]
7081 fn checks_come_from_the_node_not_the_view() {
7082 let view = view_json("new").replace(
7083 r#""reviews""#,
7084 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7085 );
7086 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7087 assert_eq!(s.pr.checks, Checks::Green);
7088 assert!(s.pr.failing.is_empty());
7089 assert_eq!(
7090 bound_head(&s.head, &s.rollup_head, Some("new")),
7091 Some("new")
7092 );
7093 }
7094
7095 #[test]
7096 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7097 for node in [
7098 None,
7099 Some("not json".to_owned()),
7100 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7101 Some(node_json("new", "SUCCESS", true)),
7102 ] {
7103 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7104 assert!(s.rollup_head.is_empty());
7105 assert_eq!(s.pr.checks, Checks::Unknown);
7106 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7107 }
7108 }
7109
7110 #[test]
7111 fn the_merge_command_is_pinned_to_the_observed_head() {
7112 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7113 let at = argv
7114 .iter()
7115 .position(|a| a == "--match-head-commit")
7116 .unwrap();
7117 assert_eq!(argv[at + 1], "deadbeef");
7118 }
7119
7120 #[tokio::test]
7121 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7122 let mut state = landing_state();
7123 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7126 stale.rollup_head = "old".to_owned();
7127 let forge = Scripted::new(
7128 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7129 vec![],
7130 );
7131 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7132 head: "new".to_owned(),
7133 });
7134 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7135 .await
7136 .unwrap();
7137 assert!(!forge.calls().contains(&"merge"));
7138 assert_eq!(state.status, RunStatus::Blocked);
7139 let why = state.merge.as_ref().unwrap().detail.clone();
7140 assert!(why.contains("new") && why.contains("old"), "{why}");
7141 }
7142
7143 #[test]
7144 fn a_refusal_read_against_another_commits_checks_is_pending() {
7145 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7146 after.rollup_head = "old".to_owned();
7147 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7148 }
7149
7150 #[tokio::test]
7151 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7152 let mut state = landing_state();
7153 let forge = Scripted::new(
7154 vec![seen("a", Checks::Red, "CLEAN", false)],
7155 vec![(true, "")],
7156 );
7157 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7158 .await
7159 .unwrap();
7160 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7161 assert_eq!(state.status, RunStatus::Merged);
7162 }
7163
7164 #[tokio::test]
7165 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7166 let mut state = landing_state();
7167 let forge = Scripted::new(
7168 vec![
7169 seen("a", Checks::Green, "CLEAN", false),
7170 seen("a", Checks::Green, "CLEAN", false),
7171 seen("b", Checks::Green, "BLOCKED", false),
7173 seen("b", Checks::Green, "CLEAN", false),
7174 ],
7175 vec![(false, REFUSED), (true, "")],
7176 );
7177 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7178 .await
7179 .unwrap();
7180 assert_eq!(
7181 forge.calls(),
7182 [
7183 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7184 ]
7185 );
7186 assert_eq!(state.status, RunStatus::Merged);
7187 }
7188
7189 fn merged_view(head: &str) -> Seen {
7190 let mut m = seen(head, Checks::Green, "CLEAN", false);
7191 m.pr.state = PrLifecycle::Merged;
7192 m
7193 }
7194
7195 fn has(argv: &[String], flag: &str) -> bool {
7196 argv.iter().any(|a| a == flag)
7197 }
7198
7199 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7200 let at = argv.iter().position(|a| a == flag)?;
7201 argv.get(at + 1).map(String::as_str)
7202 }
7203
7204 const URL: &str = "https://github.com/o/r/pull/42";
7205
7206 #[tokio::test]
7207 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7208 let mut state = landing_state();
7209 let forge = Scripted::new(
7210 vec![
7211 seen("abc", Checks::Green, "CLEAN", false),
7212 seen("abc", Checks::Green, "CLEAN", false),
7213 ],
7214 vec![(true, "")],
7215 );
7216 land_with(&mut state, URL, &forge).await.unwrap();
7217 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7218 let argv = &forge.argvs()[0];
7219 assert!(has(argv, "--squash") && has(argv, "--subject"));
7220 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7221 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7222 assert_eq!(state.status, RunStatus::Merged);
7223 assert!(state.land_armed_head.is_none());
7224 }
7225
7226 #[tokio::test]
7227 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7228 let mut state = landing_state();
7229 state.land_armed_head = Some("a".to_owned());
7230 let forge = Scripted::new(
7231 vec![seen("a", Checks::Green, "CLEAN", false)],
7232 vec![(true, ""), (true, "")],
7233 );
7234 land_with(&mut state, URL, &forge).await.unwrap();
7235 let argvs = forge.argvs();
7236 assert!(has(&argvs[0], "--disable-auto"));
7237 assert!(!has(&argvs[1], "--auto"));
7238 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7239 assert_eq!(state.status, RunStatus::Merged);
7240 assert!(state.land_armed_head.is_none());
7241 }
7242
7243 #[tokio::test]
7244 async fn an_approval_never_carries_over_to_a_new_head() {
7245 crate::run::pin_test_home();
7246 let mut state = run_state();
7247 state.config.graph.land_approval = true;
7248 let pr = green_pr();
7249 let store = ask::Questions::open();
7250
7251 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7252 .await
7253 .unwrap();
7254 let mut q = store
7255 .list()
7256 .into_iter()
7257 .find(|q| q.run == state.id)
7258 .unwrap();
7259 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7260 store.put(&mut q).unwrap();
7261 assert_eq!(
7262 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7263 .await
7264 .unwrap(),
7265 ApprovalGate::Approved,
7266 "the same head keeps its approval"
7267 );
7268
7269 assert_eq!(
7271 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7272 .await
7273 .unwrap(),
7274 ApprovalGate::Pending
7275 );
7276 let all: Vec<_> = store
7277 .list()
7278 .into_iter()
7279 .filter(|q| q.run == state.id)
7280 .collect();
7281 assert_eq!(all.len(), 2);
7282
7283 state.land_approval = None;
7285 assert_eq!(
7286 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7287 .await
7288 .unwrap(),
7289 ApprovalGate::Pending
7290 );
7291 let open = store
7292 .list()
7293 .into_iter()
7294 .filter(|q| q.run == state.id && q.status.open())
7295 .count();
7296 assert_eq!(open, 1, "the superseded question was retired");
7297 }
7298
7299 #[tokio::test]
7300 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7301 let mut state = landing_state();
7302 let forge = Scripted::new(
7303 vec![
7304 seen("a", Checks::Green, "CLEAN", false),
7305 seen("b", Checks::Green, "CLEAN", false),
7307 ],
7308 vec![(true, "")],
7309 );
7310 land_with(&mut state, URL, &forge).await.unwrap();
7311 let argvs = forge.argvs();
7312 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7313 assert!(!has(&argvs[0], "--auto"));
7314 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7315 assert_eq!(state.status, RunStatus::Merged);
7316 }
7317
7318 fn passing(label: &str) -> CheckInfo {
7319 CheckInfo {
7320 label: label.to_owned(),
7321 verdict: Verdict::Pass,
7322 required: Some(false),
7323 run: None,
7324 }
7325 }
7326
7327 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7328 (label.to_owned(), run.map(str::to_owned))
7329 }
7330
7331 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7332 LandReruns {
7333 head: head.to_owned(),
7334 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7335 }
7336 }
7337
7338 #[test]
7339 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7340 let f = [failing("test (macos)", Some("7"))];
7341 assert_eq!(
7342 rerun_step(&f, None, "h", 100),
7343 RerunStep::Rerun(vec!["7".to_owned()])
7344 );
7345 let rec = reran("h", &[("7", 100)]);
7346 let grace = crate::release_watch::RERUN_GRACE;
7347 assert_eq!(
7348 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7349 RerunStep::Grace
7350 );
7351 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7352 }
7353
7354 #[test]
7355 fn a_failure_without_a_run_id_goes_to_the_fixer() {
7356 let f = [failing("a", Some("7")), failing("ci/external", None)];
7357 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7358 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7359 }
7360
7361 #[test]
7362 fn a_moved_head_forgets_the_reruns() {
7363 let f = [failing("a", Some("7"))];
7364 let rec = reran("old", &[("7", 100)]);
7365 assert_eq!(
7366 rerun_step(&f, Some(&rec), "new", 101),
7367 RerunStep::Rerun(vec!["7".to_owned()])
7368 );
7369 }
7370
7371 #[test]
7372 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7373 let f = [
7374 failing("a", Some("7")),
7375 failing("b", Some("7")),
7376 failing("c", Some("8")),
7377 ];
7378 assert_eq!(
7379 rerun_step(&f, None, "h", 0),
7380 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7381 );
7382 let rec = reran("h", &[("7", 0)]);
7383 assert_eq!(
7384 rerun_step(&f, Some(&rec), "h", 1),
7385 RerunStep::Rerun(vec!["8".to_owned()])
7386 );
7387 }
7388
7389 #[test]
7390 fn only_blocking_failures_are_candidates_for_a_rerun() {
7391 let check = |label: &str, verdict, required, run: &str| CheckInfo {
7392 label: label.to_owned(),
7393 verdict,
7394 required,
7395 run: Some(run.to_owned()),
7396 };
7397 let got = rerunnable(&[
7398 check("passed", Verdict::Pass, Some(true), "1"),
7399 check("optional", Verdict::Fail, Some(false), "2"),
7400 check("required", Verdict::Fail, Some(true), "3"),
7401 check("unknown", Verdict::Fail, None, "4"),
7402 ]);
7403 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7404 assert_eq!(labels, ["required", "unknown"]);
7405 }
7406
7407 fn red(head: &str, run: Option<&str>) -> Seen {
7408 let mut s = seen(head, Checks::Red, "BLOCKED", false);
7409 s.pr.failing = vec!["test (macos)".to_owned()];
7410 s.contexts = vec![CheckInfo {
7411 label: "test (macos)".to_owned(),
7412 verdict: Verdict::Fail,
7413 required: Some(true),
7414 run: run.map(str::to_owned),
7415 }];
7416 s
7417 }
7418
7419 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7420
7421 #[tokio::test]
7422 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7423 let mut state = landing_state();
7424 let forge = Scripted::new(
7425 vec![
7426 red("a", Some("7")),
7427 seen("a", Checks::Pending, "BLOCKED", false),
7428 seen("a", Checks::Green, "CLEAN", false),
7429 ],
7430 vec![(true, "")],
7431 );
7432 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7433 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7434 assert!(!forge.calls().contains(&"fix"));
7435 assert_eq!(state.status, RunStatus::Merged);
7436 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7437 let argvs = forge.argvs();
7438 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7439 assert!(
7440 state
7441 .events
7442 .iter()
7443 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7444 );
7445 }
7446
7447 #[tokio::test]
7448 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7449 let mut state = landing_state();
7450 let forge = Scripted::new(
7451 vec![
7452 red("a", Some("7")),
7453 seen("a", Checks::Green, "CLEAN", false),
7454 ],
7455 vec![(true, "")],
7456 );
7457 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7458 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7460 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7461 let calls = forge.calls();
7462 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7463 let fix_at = calls.iter().position(|c| *c == "fix");
7464 assert!(fix_at.is_none_or(|f| f > rerun_at));
7465 }
7466
7467 #[tokio::test]
7468 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7469 let mut state = landing_state();
7470 let mut v = red("a", Some("7"));
7471 v.pr.checks = Checks::Pending;
7472 v.contexts.push(CheckInfo {
7473 label: "slow".to_owned(),
7474 verdict: Verdict::Pending,
7475 required: Some(true),
7476 run: Some("8".to_owned()),
7477 });
7478 let forge = Scripted::new(
7479 vec![v, seen("a", Checks::Green, "CLEAN", false)],
7480 vec![(true, "")],
7481 );
7482 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7483 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7484 assert_eq!(state.status, RunStatus::Merged);
7485 }
7486
7487 #[test]
7488 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7489 let mut v = red("a", Some("7"));
7490 v.contexts.push(CheckInfo {
7491 label: "same run, still going".to_owned(),
7492 verdict: Verdict::Pending,
7493 required: Some(true),
7494 run: Some("7".to_owned()),
7495 });
7496 assert!(rerunnable(&v.contexts).is_empty());
7497 }
7498
7499 #[tokio::test]
7500 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7501 let mut state = landing_state();
7502 state.config.graph.land_rounds = 0;
7503 let forge = Scripted::new(
7504 vec![
7505 red("a", Some("7")),
7506 seen("a", Checks::Green, "CLEAN", false),
7507 ],
7508 vec![(true, "")],
7509 );
7510 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7511 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7512 assert_eq!(state.status, RunStatus::Merged);
7513 assert!(!forge.calls().contains(&"fix"));
7514 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7515 }
7516
7517 #[tokio::test]
7518 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7519 let mut state = landing_state();
7520 state.config.graph.land_rounds = 1;
7521 let forge = Scripted::new(
7522 vec![
7523 seen("a", Checks::Green, "CLEAN", true),
7524 red("b", Some("7")),
7525 seen("b", Checks::Green, "CLEAN", false),
7526 ],
7527 vec![(true, "")],
7528 );
7529 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7530 head: "b".to_owned(),
7531 });
7532 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7533 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7534 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7535 assert_eq!(state.pr.as_ref().unwrap().round, 1);
7536 assert_eq!(state.status, RunStatus::Merged);
7537 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7538 assert_eq!(
7539 value_of(&forge.argvs()[0], "--match-head-commit"),
7540 Some("b")
7541 );
7542 }
7543
7544 #[tokio::test]
7545 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7546 let mut state = landing_state();
7547 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7548 *forge.rerun_fails.lock().unwrap() = true;
7549 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7550 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7551 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7552 assert_eq!(
7553 forge.calls().iter().filter(|c| **c == "fix").count(),
7554 1,
7555 "{:?}",
7556 forge.calls()
7557 );
7558 }
7559
7560 #[tokio::test]
7561 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7562 let mut state = landing_state();
7563 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7564 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7566 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7567 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7568 assert!(forge.reruns.lock().unwrap().is_empty());
7569 }
7570
7571 #[tokio::test]
7572 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7573 let mut state = landing_state();
7574 state.config.graph.rerun_flaky_checks = false;
7575 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7576 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7577 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7578 assert!(forge.reruns.lock().unwrap().is_empty());
7579 assert!(forge.calls().contains(&"fix"));
7580
7581 let mut state = landing_state();
7582 let forge = Scripted::new(vec![red("a", None)], vec![]);
7583 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7584 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7585 assert!(forge.reruns.lock().unwrap().is_empty());
7586 assert!(forge.calls().contains(&"fix"));
7587 }
7588
7589 fn names(xs: &[&str]) -> BTreeSet<String> {
7590 xs.iter().map(|x| (*x).to_owned()).collect()
7591 }
7592
7593 #[test]
7594 fn a_required_check_the_rollup_never_listed_is_named() {
7595 let req = names(&["build"]);
7596 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7597 assert!(why.contains("never reported: build"), "{why}");
7598 assert!(!why.contains("probably waiting for a review"), "{why}");
7599 }
7600
7601 #[test]
7602 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7603 let why = waiting_on("BLOCKED", &[passing("review")], None);
7604 assert!(why.contains("could not be read"), "{why}");
7605 assert!(!why.contains("probably waiting for a review"), "{why}");
7606 }
7607
7608 #[test]
7609 fn all_required_reported_keeps_the_review_guess() {
7610 let req = names(&["build"]);
7611 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7612 assert!(why.contains("probably waiting for a review"), "{why}");
7613 assert!(!why.contains("never reported"), "{why}");
7614 }
7615
7616 #[test]
7617 fn required_names_match_the_rollup_ignoring_case_only() {
7618 let req = names(&["Build"]);
7619 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7620 assert!(!why.contains("never reported"), "{why}");
7621 }
7622
7623 #[test]
7624 fn required_contexts_are_read_from_protection_and_rulesets() {
7625 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7626 assert_eq!(
7627 parse_classic_required(classic),
7628 Some(names(&["build", "lint"]))
7629 );
7630 let rules = r#"[{"type":"pull_request","parameters":{}},
7631 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7632 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7633 assert_eq!(parse_ruleset_required("nope"), None);
7634 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7635 }
7636
7637 #[test]
7638 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7639 let shown = BTreeSet::new();
7640 let ok = seen("a", Checks::Green, "CLEAN", false);
7641 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7642 assert!(guard(Some(&ok)));
7643 assert!(!guard(None));
7644 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7645 let mut stale = ok.clone();
7646 stale.rollup_head = "old".to_owned();
7647 assert!(!guard(Some(&stale)));
7648 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7649 assert!(!guard(Some(&merged_view("a"))));
7650 }
7651
7652 #[test]
7653 fn the_rollup_node_carries_whether_each_check_is_required() {
7654 let node = node_json("new", "SUCCESS", false)
7655 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7656 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7657 assert_eq!(s.contexts.len(), 1);
7658 assert_eq!(s.contexts[0].required, Some(true));
7659 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7660 assert_eq!(s.contexts[0].required, None);
7661 }
7662
7663 #[tokio::test]
7664 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7665 let mut state = landing_state();
7666 state.land_armed_head = Some("a".to_owned());
7667 let forge = Scripted::new(
7668 vec![seen("a", Checks::Green, "CLEAN", true)],
7669 vec![(false, "disable exploded")],
7670 );
7671 land_with(&mut state, URL, &forge).await.unwrap();
7672 assert!(!forge.calls().contains(&"fix"));
7673 assert_eq!(state.status, RunStatus::Blocked);
7674 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7675 let why = state.merge.as_ref().unwrap().detail.clone();
7676 assert!(why.contains("disable exploded"), "{why}");
7677 }
7678}