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