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