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 const REPAIR: &str = "fix findings and re-review";
618
619pub fn repair_available(state: &RunState) -> bool {
622 state.reviews.last().is_some_and(|r| {
623 state.open_findings().iter().any(|f| f.severity.blocks())
624 || r.final_votes()
625 .iter()
626 .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627 })
628}
629
630pub fn approval_choices(state: &RunState) -> Vec<String> {
632 let mut choices = vec![APPROVE.to_owned(), HOLD.to_owned()];
633 if repair_available(state) {
634 choices.push(REPAIR.to_owned());
635 }
636 choices
637}
638
639pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
642 if q.node == APPROVAL_NODE
643 && q.status.open()
644 && repair_available(state)
645 && state
646 .land_approval
647 .as_ref()
648 .is_some_and(|a| a.question == q.id)
649 && !q.choices.iter().any(|c| c == REPAIR)
650 {
651 q.choices.push(REPAIR.to_owned());
652 q.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
653 true
654 } else {
655 false
656 }
657}
658
659pub fn unhedged(message: &str, quote: &str) -> bool {
667 let quote = quote.trim();
668 !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
669}
670
671fn hedged(text: &str) -> bool {
675 const WORDS: &[&str] = &[
676 "maybe",
677 "probably",
678 "perhaps",
679 "might",
680 "if",
681 "unless",
682 "not",
683 "no",
684 "nope",
685 "stop",
686 "dont",
687 "abort",
688 "revert",
689 "undo",
690 "never",
691 "wait",
692 "hold",
693 "cancel",
694 "but",
695 "think",
696 "guess",
697 "suppose",
698 "unsure",
699 "yet",
700 "except",
701 "only",
702 "cannot",
703 "should",
704 "once",
705 "provided",
706 "providing",
707 "when",
708 "whenever",
709 "after",
710 "until",
711 "before",
712 "assuming",
713 "given",
714 "while",
715 "whether",
716 "depending",
717 "pending",
718 ];
719 const JA: &[&str] = &[
720 "かも",
721 "たぶん",
722 "多分",
723 "なら",
724 "たら",
725 "ちょっと待",
726 "しないで",
727 "しない",
728 "保留",
729 "まだ",
730 "ただし",
731 "やめ",
732 "いや",
733 "止め",
734 "だめ",
735 "ダメ",
736 "じゃない",
737 "ではない",
738 "ですか",
739 "かな",
740 "でしょう",
741 "思う",
742 "でも",
743 "けど",
744 "ただ",
745 "次第",
746 "場合",
747 "限り",
748 "条件",
749 "とき",
750 "まで",
751 "後で",
752 ];
753 if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
754 return true;
755 }
756 text.to_lowercase()
757 .replace('\u{2019}', "'")
758 .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
759 .filter(|w| !w.is_empty())
760 .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
761}
762
763fn retracts(message: &str) -> bool {
766 let lower = message.to_lowercase();
767 [
768 "やっぱ",
769 "待って",
770 "撤回",
771 "never mind",
772 "actually",
773 "on second thought",
774 ]
775 .iter()
776 .any(|w| lower.contains(w))
777 || lower
778 .split(|c: char| !c.is_ascii_alphabetic())
779 .any(|w| w == "wait")
780}
781
782pub const APPROVAL_NODE: &str = "land-approval";
788
789pub const DIFF_MAX_LINES: usize = 400;
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
800pub enum Approval {
801 Merge,
803 Repair,
805 Hold,
807}
808
809pub fn approval(answer: Option<&str>) -> Approval {
817 match answer {
818 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
819 Some(a) if a.trim() == REPAIR => Approval::Repair,
820 _ => Approval::Hold,
821 }
822}
823
824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826enum ApprovalGate {
827 Approved,
829 Repair,
831 Held,
834 Pending,
836}
837
838fn esc(s: &str) -> String {
848 let mut out = String::with_capacity(s.len());
849 for c in s.chars() {
850 match c {
851 '&' => out.push_str("&"),
852 '<' => out.push_str("<"),
853 '>' => out.push_str(">"),
854 '"' => out.push_str("""),
855 '\'' => out.push_str("'"),
856 _ => out.push(c),
857 }
858 }
859 out
860}
861
862#[derive(Debug, Clone, PartialEq, Eq)]
864struct StatRow {
865 path: String,
866 added: Option<u64>,
868 removed: Option<u64>,
869}
870
871impl StatRow {
872 fn churn(&self) -> u64 {
875 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
876 }
877}
878
879fn parse_numstat(numstat: &str) -> Vec<StatRow> {
885 let mut rows: Vec<StatRow> = numstat
886 .lines()
887 .filter_map(|line| {
888 let mut parts = line.splitn(3, '\t');
889 let added = parts.next()?.trim();
890 let removed = parts.next()?.trim();
891 let path = parts.next()?.trim();
892 if path.is_empty() {
893 return None;
894 }
895 Some(StatRow {
896 path: path.to_owned(),
897 added: added.parse().ok(),
898 removed: removed.parse().ok(),
899 })
900 })
901 .collect();
902 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
905 rows
906}
907
908fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
917 if line.starts_with("+++") || line.starts_with("---") {
918 (" ", "color:#57606a;font-weight:600", line)
919 } else if let Some(body) = line.strip_prefix('+') {
920 ("+", "background:#e6ffec;color:#0a3622", body)
921 } else if let Some(body) = line.strip_prefix('-') {
922 ("-", "background:#ffebe9;color:#5c1a17", body)
923 } else if line.starts_with("@@") {
924 ("~", "background:#eef2ff;color:#3730a3", line)
925 } else if let Some(body) = line.strip_prefix(' ') {
926 (" ", "", body)
927 } else {
928 (" ", "color:#57606a;font-weight:600", line)
929 }
930}
931
932struct Words {
941 html_lang: &'static str,
942 task: &'static str,
943 what_changed: &'static str,
944 review_verdict: &'static str,
945 reviewer: &'static str,
946 reviewer_no_answer: &'static str,
947 checks: &'static str,
948 nothing_failing: &'static str,
949 files_changed: &'static str,
950 commits: &'static str,
951 no_commits: &'static str,
952 comments: &'static str,
953 no_comments: &'static str,
954 diff: &'static str,
955 truncated: &'static str,
956 lands_as: &'static str,
957}
958
959const EN: Words = Words {
960 html_lang: "en",
961 task: "Task",
962 what_changed: "What changed",
963 review_verdict: "Review verdict",
964 reviewer: "Reviewer",
965 reviewer_no_answer: "produced no answer",
966 checks: "Checks",
967 nothing_failing: "Nothing failing.",
968 files_changed: "file(s) changed",
969 commits: "Commits being squashed",
970 no_commits: "No commit subjects could be read from the branch.",
971 comments: "Review comments",
972 no_comments: "Nothing outstanding at this observation.",
973 diff: "Diff",
974 truncated: "Truncated",
975 lands_as: "They land as one commit titled",
976};
977
978const JA: Words = Words {
979 html_lang: "ja",
980 task: "タスク",
981 what_changed: "変更内容",
982 review_verdict: "レビューの結論",
983 reviewer: "レビュアー",
984 reviewer_no_answer: "回答なし",
985 checks: "チェック",
986 nothing_failing: "失敗しているものはありません。",
987 files_changed: "ファイル変更",
988 commits: "squash されるコミット",
989 no_commits: "ブランチからコミット件名を読めませんでした。",
990 comments: "レビューコメント",
991 no_comments: "この時点で未対応のものはありません。",
992 diff: "差分",
993 truncated: "省略",
994 lands_as: "これらは次の件名の1コミットとして入ります:",
995};
996
997impl Words {
998 fn lands_as_tail(&self) -> &'static str {
1002 if self.html_lang == "ja" {
1003 "。この件名も承認の対象です。"
1004 } else {
1005 ", which you are approving too."
1006 }
1007 }
1008
1009 fn approval_summary(&self, number: u64, subject: &str) -> String {
1011 if self.html_lang == "ja" {
1012 format!("プルリクエスト #{number} をマージ: {subject}")
1013 } else {
1014 format!("merge pull request #{number}: {subject}")
1015 }
1016 }
1017
1018 fn approval_detail(
1020 &self,
1021 url: &str,
1022 base: &str,
1023 subject: &str,
1024 contested: Option<&ContestedHandoff>,
1025 ) -> String {
1026 let body = if self.html_lang == "ja" {
1027 format!(
1028 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1029 できる状態です。差分の要約・パッチ・squash されるコミットは\
1030 下のパネルにあります。"
1031 )
1032 } else {
1033 format!(
1034 "{url} is green and ready to squash into `{base}` as `{subject}`. \
1035 The panel holds the diffstat, the patch and the commits being squashed."
1036 )
1037 };
1038 match contested {
1039 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1040 None => body,
1041 }
1042 }
1043
1044 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1047 const SHOWN: usize = 5;
1048 const TITLE_CHARS: usize = 100;
1049 let ja = self.html_lang == "ja";
1050 let mut out = if ja {
1051 format!(
1052 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1053 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1054 )
1055 } else {
1056 format!(
1057 "{url} is held for approval although merge approvals are off: the \
1058 review ended with blocking findings still open and a reviewer \
1059 voting reject.\n"
1060 )
1061 };
1062 for f in c.findings.iter().take(SHOWN) {
1063 let at = match (&f.file, f.line) {
1064 (Some(file), Some(line)) => format!("{file}:{line}"),
1065 (Some(file), None) => file.clone(),
1066 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1067 };
1068 let title: String = f.title.chars().take(TITLE_CHARS).collect();
1069 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1070 }
1071 if c.findings.len() > SHOWN {
1072 let more = c.findings.len() - SHOWN;
1073 let _ = writeln!(
1074 out,
1075 "{}",
1076 if ja {
1077 format!("- ほか {more} 件")
1078 } else {
1079 format!("- and {more} more")
1080 }
1081 );
1082 }
1083 let seats: Vec<String> = c
1084 .rejecters
1085 .iter()
1086 .map(|(seat, agent)| format!("#{seat} ({agent})"))
1087 .collect();
1088 let _ = write!(
1089 out,
1090 "{} {}",
1091 if ja {
1092 "却下したレビュアー:"
1093 } else {
1094 "Rejected by reviewer:"
1095 },
1096 seats.join(", ")
1097 );
1098 out
1099 }
1100
1101 fn truncated_note(
1103 &self,
1104 omitted: usize,
1105 total: usize,
1106 shown: usize,
1107 where_: &str,
1108 base: &str,
1109 head: &str,
1110 ) -> String {
1111 if self.html_lang == "ja" {
1112 format!(
1113 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1114 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1115 プルリクエストにあります。"
1116 )
1117 } else {
1118 format!(
1119 "{omitted} of {total} diff lines omitted after the first {shown}. \
1120 The whole patch is in <code>{where_}</code> \
1121 (<code>git diff {base}...{head}</code>) and on the pull request."
1122 )
1123 }
1124 }
1125}
1126
1127fn words(language: &str) -> &'static Words {
1130 if crate::lang::is_japanese(language) {
1131 &JA
1132 } else {
1133 &EN
1134 }
1135}
1136
1137pub fn approval_panel(
1149 state: &RunState,
1150 pr: &PrState,
1151 diffstat: &str,
1152 diff: &str,
1153 commits: &[String],
1154 subject: &str,
1155) -> String {
1156 let rows = parse_numstat(diffstat);
1157 let w = words(&state.config.graph.language);
1158 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1159
1160 let _ = writeln!(
1161 h,
1162 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1163 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1164 w.html_lang
1165 );
1166 let _ = writeln!(
1167 h,
1168 "<title>merge #{} — {}</title>\n</head>",
1169 pr.number,
1170 esc(subject)
1171 );
1172 h.push_str(
1173 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1174 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1175 word-break:break-word\">\n",
1176 );
1177
1178 let _ = writeln!(
1180 h,
1181 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1182 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1183 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1184 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1185 <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1186 pr.number,
1187 esc(&state.base_branch),
1188 esc(subject),
1189 esc(&state.id),
1190 esc(&pr.url),
1191 esc(&pr.url),
1192 );
1193
1194 let _ = writeln!(
1197 h,
1198 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1199 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1200 w.task,
1201 esc(&state.instruction)
1202 );
1203
1204 if let Some(summary) = state
1206 .winner()
1207 .map(|c| c.summary.as_str())
1208 .filter(|s| !s.is_empty())
1209 {
1210 let _ = writeln!(
1211 h,
1212 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1213 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1214 w.what_changed,
1215 esc(summary)
1216 );
1217 }
1218
1219 if let Some(round) = state.reviews.last() {
1222 let _ = writeln!(
1223 h,
1224 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1225 w.review_verdict
1226 );
1227 for r in &round.reviews {
1228 let body = match &r.failed {
1235 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1236 None => esc(&r.summary),
1237 };
1238 let _ = writeln!(
1239 h,
1240 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1241 border-radius:6px\">\
1242 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1243 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1244 w.reviewer,
1245 r.reviewer,
1246 esc(&r.agent),
1247 body,
1248 );
1249 }
1250 }
1251
1252 let _ = writeln!(
1253 h,
1254 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1255 w.checks,
1256 esc(pr.checks.as_str())
1257 );
1258 if pr.failing.is_empty() {
1259 let _ = writeln!(
1260 h,
1261 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1262 w.nothing_failing
1263 );
1264 } else {
1265 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1266 for f in &pr.failing {
1267 let _ = writeln!(h, "<li>{}</li>", esc(f));
1268 }
1269 h.push_str("</ul>\n");
1270 }
1271
1272 let _ = writeln!(
1275 h,
1276 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1277 rows.len(),
1278 w.files_changed
1279 );
1280 h.push_str(
1281 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1282 <thead><tr>\
1283 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1284 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1285 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1286 </th></tr></thead>\n<tbody>\n",
1287 );
1288 let mut total_added = 0u64;
1289 let mut total_removed = 0u64;
1290 for r in &rows {
1291 total_added += r.added.unwrap_or(0);
1292 total_removed += r.removed.unwrap_or(0);
1293 let cell = |n: Option<u64>| match n {
1294 Some(n) => n.to_string(),
1295 None => "bin".to_owned(),
1296 };
1297 let _ = writeln!(
1298 h,
1299 "<tr>\
1300 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1301 font-family:ui-monospace,monospace\">{}</td>\
1302 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1303 color:#0a3622\">{}</td>\
1304 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1305 color:#5c1a17\">{}</td></tr>",
1306 esc(&r.path),
1307 cell(r.added),
1308 cell(r.removed),
1309 );
1310 }
1311 let _ = writeln!(
1312 h,
1313 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1314 <td style=\"padding:4px 2px\">total</td>\
1315 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1316 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1317 </tr></tfoot>\n</table>"
1318 );
1319
1320 let _ = writeln!(
1322 h,
1323 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1324 w.commits
1325 );
1326 if commits.is_empty() {
1327 h.push_str(&format!(
1328 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1329 w.no_commits
1330 ));
1331 } else {
1332 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1333 for c in commits {
1334 let _ = writeln!(h, "<li>{}</li>", esc(c));
1335 }
1336 h.push_str("</ol>\n");
1337 }
1338 let _ = writeln!(
1339 h,
1340 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1341 w.lands_as,
1342 esc(subject),
1343 w.lands_as_tail()
1344 );
1345
1346 let _ = writeln!(
1348 h,
1349 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1350 w.comments
1351 );
1352 if pr.review_comments.is_empty() {
1353 h.push_str(&format!(
1354 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1355 w.no_comments
1356 ));
1357 } else {
1358 for c in &pr.review_comments {
1359 let anchor = match (&c.path, c.line) {
1360 (Some(p), Some(l)) => format!("{p}:{l}"),
1361 (Some(p), None) => p.clone(),
1362 _ => "pull request thread".to_owned(),
1363 };
1364 let _ = writeln!(
1365 h,
1366 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1367 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1368 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1369 esc(&c.author),
1370 esc(&anchor),
1371 esc(&tail(&c.body, 800)),
1372 );
1373 }
1374 }
1375
1376 let total = diff.lines().count();
1378 let shown = total.min(DIFF_MAX_LINES);
1379 let _ = writeln!(
1380 h,
1381 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1382 w.diff
1383 );
1384 h.push_str(
1385 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1386 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1387 );
1388 for line in diff.lines().take(shown) {
1389 let (gutter, style, body) = diff_row(line);
1390 let _ = writeln!(
1391 h,
1392 "<div style=\"display:flex;{style}\">\
1393 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1394 border-right:1px solid #d0d7de\">{gutter}</span>\
1395 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1396 esc(body),
1397 );
1398 }
1399 h.push_str("</div>\n");
1400 if total > shown {
1401 let omitted = total - shown;
1402 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1403 let where_ = state.winner().map_or_else(
1404 || state.repo.display().to_string(),
1405 |w| w.worktree.display().to_string(),
1406 );
1407 let _ = writeln!(
1408 h,
1409 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1410 font-size:13px\">{}: {}</p>",
1411 w.truncated,
1412 w.truncated_note(
1413 omitted,
1414 total,
1415 shown,
1416 &esc(&where_),
1417 &esc(&state.base_branch),
1418 &esc(head),
1419 ),
1420 );
1421 }
1422
1423 h.push_str("</body>\n</html>\n");
1424 h
1425}
1426
1427fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1431 if state.config.graph.hold_contested_merge {
1432 state.contested_handoff.clone()
1433 } else {
1434 None
1435 }
1436}
1437
1438pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1447 let mut s = format!(
1448 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1449 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1450 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1451 request open. Silence is a hold: the owner not answering never merges. Only \
1452 the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1453 own words, merges. Whether their wording is a clear, unconditional instruction \
1454 is your judgement alone: if it is doubtful, conditional, retracted or a \
1455 question, do not settle - ask back with `magi ask --thread`. Doubt and \
1456 silence are a hold.\n\n\
1457 This brief is a snapshot from when you were attached: check `magi show {run}` \
1458 and `gh pr view` (read-only) before telling the owner anything current. \
1459 You run with permission to write the question record, and what keeps you \
1460 from touching anything else is this brief and your instructions - so do \
1461 not change files, branches or the pull request.",
1462 run = q.run
1463 );
1464 if q.choices.iter().any(|c| c == REPAIR) {
1465 s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1466 }
1467 let Some(state) = state else {
1468 s.push_str(
1469 "\n\nThe run's record could not be read, so the pull request, the panel \
1470 summary and any contested findings are not known to you beyond the \
1471 question's own text. Say so to the owner rather than guessing.",
1472 );
1473 return s;
1474 };
1475 if let Some(pr) = &state.pr {
1476 s.push_str(&format!(
1477 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1478 pr.number, pr.url, pr.state
1479 ));
1480 }
1481 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1482 if let Some(w) = state.winner() {
1483 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1484 }
1485 match contested_to_ask(state) {
1486 Some(c) => {
1487 s.push_str(
1488 "\n\nThis question was filed although merge approvals are off, because \
1489 the review hand-off is contested. Open findings:",
1490 );
1491 for f in &c.findings {
1492 let at = match (&f.file, f.line) {
1493 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1494 (Some(file), None) => format!(" ({file})"),
1495 _ => String::new(),
1496 };
1497 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1498 }
1499 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1500 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1501 }
1502 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1503 }
1504 s
1505}
1506
1507async fn approval_gate(
1527 state: &mut RunState,
1528 pr: &PrState,
1529 subject: &str,
1530 contested: Option<&ContestedHandoff>,
1531 head: &str,
1532) -> Result<ApprovalGate> {
1533 let store = ask::Questions::open();
1534 let reusable = state
1539 .land_approval
1540 .as_ref()
1541 .filter(|a| a.head.eq_ignore_ascii_case(head))
1542 .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1543 if reusable.is_none() {
1544 for stale in store
1545 .list()
1546 .into_iter()
1547 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1548 {
1549 let why = "the pull request moved to a different head commit; asked again about it";
1550 if let Err(e) = store.update(&stale.id, |q| {
1551 q.abandon(why);
1552 Ok(())
1553 }) {
1554 tracing::warn!("could not retire the superseded approval question: {e:#}");
1555 }
1556 }
1557 }
1558
1559 let q = match reusable {
1560 Some(mut q) => {
1561 if upgrade_repair_choice(&mut q, state) {
1562 q = store
1563 .update(&q.id, |current| {
1564 upgrade_repair_choice(current, state);
1565 Ok(())
1566 })?
1567 .0;
1568 }
1569 q
1570 }
1571 None => {
1572 let worktree = match state.winner() {
1573 Some(w) => w.worktree.clone(),
1574 None => state.repo.clone(),
1575 };
1576 let head = if head.is_empty() {
1579 state
1580 .winner()
1581 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1582 } else {
1583 head.to_owned()
1584 };
1585 let remote = &state.config.merge.remote;
1588 let tracking = format!("{remote}/{}", state.base_branch);
1589 let base = if git::rev_exists(&worktree, &tracking).await {
1590 tracking
1591 } else {
1592 String::new()
1593 };
1594 let range = format!("{base}...{head}");
1595 let numstat = if base.is_empty() {
1599 String::new()
1600 } else {
1601 git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1602 .await
1603 .map(|o| o.stdout)
1604 .unwrap_or_default()
1605 };
1606 let diff = if base.is_empty() {
1607 String::new()
1608 } else {
1609 git::diff(&worktree, &base, &head).await.unwrap_or_default()
1610 };
1611 let commits: Vec<String> = if base.is_empty() {
1612 Vec::new()
1613 } else {
1614 git::git_raw(
1615 &worktree,
1616 &[
1617 "log",
1618 "--reverse",
1619 "--format=%s",
1620 &format!("{base}..{head}"),
1621 ],
1622 )
1623 .await
1624 .map(|o| o.stdout)
1625 .unwrap_or_default()
1626 .lines()
1627 .filter(|l| !l.trim().is_empty())
1628 .map(str::to_owned)
1629 .collect()
1630 };
1631
1632 let w = words(&state.config.graph.language);
1633 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1634 let mut fresh = ask::Question::new(
1635 state.id.clone(),
1636 APPROVAL_NODE.to_owned(),
1637 "land".to_owned(),
1638 w.approval_summary(pr.number, subject),
1639 w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1640 approval_choices(state),
1641 );
1642 if fresh.choices.iter().any(|c| c == REPAIR) {
1643 fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1644 }
1645 store
1646 .put_panel(&mut fresh, &html, &[])
1647 .context("write the merge approval panel")?;
1648 store
1649 .put(&mut fresh)
1650 .context("file the merge approval question")?;
1651 state.land_approval = Some(LandApproval {
1652 question: fresh.id.clone(),
1653 head: head.clone(),
1654 });
1655 state.event(
1656 "land",
1657 format!("asking for merge approval ({})", fresh.short()),
1658 );
1659 state.save()?;
1660 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1661 tracing::warn!(
1665 "could not notify about merge approval question {}: {e:#} - \
1666 the web UI is the only surface for it now",
1667 fresh.short()
1668 );
1669 }
1670 fresh
1671 }
1672 };
1673
1674 Ok(match q.status {
1675 ask::QuestionStatus::Open => ApprovalGate::Pending,
1676 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1680 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1684 Approval::Merge => ApprovalGate::Approved,
1685 Approval::Repair if repair_available(state) => {
1686 if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1687 state.merge_repairs.push(crate::run::MergeRepair {
1688 question: q.id.clone(),
1689 head: head.to_owned(),
1690 pr: pr.url.clone(),
1691 reviews: state.reviews.clone(),
1692 gate: state.gate.clone(),
1693 gate_head: state.gate_head.clone(),
1694 gate_fixes: state.gate_fixes.clone(),
1695 pre_gate: state.pre_gate.clone(),
1696 phase: crate::run::MergeRepairPhase::Requested,
1697 failure: None,
1698 });
1699 state.land_approval = None;
1700 state.event(
1701 "repair",
1702 "owner requested fix findings and re-review; merge is not approved",
1703 );
1704 state.save()?;
1705 }
1706 ApprovalGate::Repair
1707 }
1708 Approval::Repair | Approval::Hold => ApprovalGate::Held,
1709 },
1710 })
1711}
1712
1713fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1716 let mut failing = Vec::new();
1717 let mut pending = false;
1718 let mut unknown = false;
1719 for check in rollup {
1720 match check.verdict() {
1721 Verdict::Pass => {}
1722 Verdict::Pending => pending = true,
1723 Verdict::Fail => failing.push(check.label()),
1724 Verdict::Unknown => unknown = true,
1725 }
1726 }
1727 let checks = if rollup.is_empty() {
1728 Checks::Unknown
1729 } else if pending {
1730 Checks::Pending
1731 } else if !failing.is_empty() {
1732 Checks::Red
1733 } else if unknown {
1734 Checks::Unknown
1735 } else {
1736 Checks::Green
1737 };
1738 (checks, failing)
1739}
1740
1741pub fn parse_pr(json: &str) -> Result<PrState> {
1744 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1745 let state = match raw.state.to_ascii_uppercase().as_str() {
1746 "OPEN" => PrLifecycle::Open,
1747 "MERGED" => PrLifecycle::Merged,
1748 "CLOSED" => PrLifecycle::Closed,
1749 other => bail!("unknown pull request state `{other}`"),
1750 };
1751
1752 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1753
1754 let mut review_comments = Vec::new();
1755 for r in raw.reviews {
1756 push_if_outstanding(
1757 &mut review_comments,
1758 ReviewComment {
1759 author: r.author.login,
1760 path: None,
1761 line: None,
1762 body: r.body,
1763 },
1764 );
1765 }
1766 for c in raw.comments {
1767 push_if_outstanding(
1768 &mut review_comments,
1769 ReviewComment {
1770 author: c.author.login,
1771 path: None,
1772 line: None,
1773 body: c.body,
1774 },
1775 );
1776 }
1777
1778 Ok(PrState {
1779 url: raw.url,
1780 number: raw.number,
1781 state,
1782 checks,
1783 failing,
1784 review_comments,
1785 blocking: Blocking::of(&raw.merge_state_status),
1786 })
1787}
1788
1789#[derive(Debug, Clone, PartialEq, Eq)]
1791pub(crate) struct CheckView {
1792 pub name: String,
1793 pub verdict: Verdict,
1794 pub run: Option<String>,
1796 pub url: Option<String>,
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Eq)]
1803pub(crate) struct RollupView {
1804 pub url: String,
1805 pub number: u64,
1806 pub state: PrLifecycle,
1807 pub head: String,
1808 pub checks: Vec<CheckView>,
1809}
1810
1811pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1814 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1815 let state = match raw.state.to_ascii_uppercase().as_str() {
1816 "OPEN" => PrLifecycle::Open,
1817 "MERGED" => PrLifecycle::Merged,
1818 "CLOSED" => PrLifecycle::Closed,
1819 other => bail!("unknown pull request state `{other}`"),
1820 };
1821 let checks = raw
1822 .status_check_rollup
1823 .iter()
1824 .map(|c| CheckView {
1825 name: c.label(),
1826 verdict: c.verdict(),
1827 run: c.url().and_then(run_of),
1828 url: c.url().map(str::to_owned),
1829 })
1830 .collect();
1831 Ok(RollupView {
1832 url: raw.url,
1833 number: raw.number,
1834 state,
1835 head: raw.head_ref_oid,
1836 checks,
1837 })
1838}
1839
1840pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1850 let view = gh(
1851 repo,
1852 &[
1853 "pr".to_owned(),
1854 "view".to_owned(),
1855 pr_url.to_owned(),
1856 "--json".to_owned(),
1857 "state".to_owned(),
1858 ],
1859 )
1860 .await?;
1861 if !view.0 {
1862 bail!("gh pr view {pr_url}: {}", view.1);
1863 }
1864 Ok(parse_pr(&view.1)?.state)
1868}
1869
1870#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1874pub struct ExternalMerge {
1875 pub url: String,
1877 pub number: u64,
1879}
1880
1881#[derive(Debug, Deserialize)]
1882#[serde(rename_all = "camelCase")]
1883struct GhMergedPr {
1884 url: String,
1885 number: u64,
1886 merged_at: String,
1887 base_ref_name: String,
1888}
1889
1890fn pick_merged_pr(
1904 json: &str,
1905 base_branch: &str,
1906 created_at: Timestamp,
1907) -> Result<Option<ExternalMerge>> {
1908 let raw: Vec<GhMergedPr> =
1909 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1910 let mut matches: Vec<ExternalMerge> = Vec::new();
1911 for pr in raw {
1912 if pr.base_ref_name != base_branch {
1913 continue;
1914 }
1915 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1916 continue;
1917 };
1918 if merged_at < created_at {
1919 continue;
1920 }
1921 matches.push(ExternalMerge {
1922 url: pr.url,
1923 number: pr.number,
1924 });
1925 }
1926 if matches.len() == 1 {
1927 Ok(matches.pop())
1928 } else {
1929 Ok(None)
1930 }
1931}
1932
1933#[derive(Debug, Clone, PartialEq, Eq)]
1935pub enum OpenPr {
1936 None,
1938 One {
1940 url: String,
1942 title: String,
1944 },
1945 Many(Vec<String>),
1947}
1948
1949#[derive(Debug, Deserialize)]
1950#[serde(rename_all = "camelCase")]
1951struct GhOpenPr {
1952 url: String,
1956 #[serde(default)]
1957 title: String,
1958 base_ref_name: String,
1959 #[serde(default)]
1962 is_cross_repository: bool,
1963}
1964
1965pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1971 let raw: Vec<GhOpenPr> =
1972 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1973 let mut hits: Vec<GhOpenPr> = raw
1974 .into_iter()
1975 .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
1976 .collect();
1977 Ok(match hits.len() {
1978 0 => OpenPr::None,
1979 1 => {
1980 let p = hits.remove(0);
1981 OpenPr::One {
1982 url: p.url,
1983 title: p.title,
1984 }
1985 }
1986 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1987 })
1988}
1989
1990pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1994 let (ok, out) = gh(
1995 repo,
1996 &[
1997 "pr".to_owned(),
1998 "list".to_owned(),
1999 "--head".to_owned(),
2000 branch.to_owned(),
2001 "--base".to_owned(),
2002 base.to_owned(),
2003 "--state".to_owned(),
2004 "open".to_owned(),
2005 "--json".to_owned(),
2006 "number,url,title,baseRefName,isCrossRepository".to_owned(),
2007 ],
2008 )
2009 .await?;
2010 if !ok {
2011 bail!("gh pr list failed: {out}");
2012 }
2013 pick_open_pr(&out, base)
2014}
2015
2016#[derive(Debug, Deserialize)]
2017#[serde(rename_all = "camelCase")]
2018struct GhPrHead {
2019 head_ref_name: String,
2020 base_ref_name: String,
2021 state: String,
2022 is_cross_repository: bool,
2025 head_ref_oid: String,
2028}
2029
2030#[derive(Debug, Clone, PartialEq, Eq)]
2032pub struct Refusal {
2033 pub retry: bool,
2036 pub why: String,
2038}
2039
2040impl Refusal {
2041 fn final_(why: String) -> Self {
2042 Self { retry: false, why }
2043 }
2044}
2045
2046pub fn closable(
2055 json: &str,
2056 branch: &str,
2057 base: &str,
2058 verified: &[String],
2059) -> std::result::Result<(), Refusal> {
2060 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2061 retry: true,
2062 why: format!("could not read the pull request ({e})"),
2063 })?;
2064 if pr.head_ref_name != branch {
2065 return Err(Refusal::final_(format!(
2066 "its head is `{}`, not this run's `{branch}`",
2067 pr.head_ref_name
2068 )));
2069 }
2070 if pr.is_cross_repository {
2071 return Err(Refusal::final_("its head lives in a fork".to_owned()));
2072 }
2073 if pr.base_ref_name != base {
2074 return Err(Refusal::final_(format!(
2075 "it targets `{}`, not `{base}`",
2076 pr.base_ref_name
2077 )));
2078 }
2079 if !pr.state.eq_ignore_ascii_case("open") {
2080 return Err(Refusal::final_(format!(
2081 "it is already {}",
2082 pr.state.to_ascii_lowercase()
2083 )));
2084 }
2085 if !verified.contains(&pr.head_ref_oid) {
2089 return Err(Refusal {
2090 retry: true,
2091 why: format!(
2092 "its head {} is not a commit this run checked against the base",
2093 crate::already::short_sha(&pr.head_ref_oid)
2094 ),
2095 });
2096 }
2097 Ok(())
2098}
2099
2100async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2105 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2106 return false;
2107 };
2108 is_forge_url(url.trim())
2109}
2110
2111fn is_forge_url(url: &str) -> bool {
2112 url.contains("://") && !url.starts_with("file://")
2113 || url
2114 .split_once(':')
2115 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2116}
2117
2118fn forge_unavailable(message: &str) -> bool {
2121 message.contains("known GitHub host") || message.contains("spawn gh")
2122}
2123
2124pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2127 let how = match evidence.proof {
2128 crate::already::Proof::PatchId => format!(
2129 "carried by commit {} on `{base}` with the same patch",
2130 evidence.names()
2131 ),
2132 crate::already::Proof::Ancestry => {
2133 format!("already in the history of `{base}` as {}", evidence.names())
2134 }
2135 crate::already::Proof::Tree => format!(
2136 "already part of `{base}` (merging this branch changes nothing at {})",
2137 crate::already::short_sha(&evidence.tip)
2138 ),
2139 };
2140 format!(
2141 "Closing: everything this branch adds is {how}, so there is nothing left to \
2142 land. This pull request was closed automatically after that was verified; \
2143 reopen it if you disagree."
2144 )
2145}
2146
2147pub async fn close_superseded_pr(
2159 state: &mut RunState,
2160 branch: &str,
2161 evidence: &crate::already::Evidence,
2162 verified: &[String],
2163) -> Result<std::result::Result<String, String>> {
2164 let repo = state.repo.clone();
2165 let base = state.base_branch.clone();
2166 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2167 Some(p) => p.url.clone(),
2168 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2173 return Ok(Err(
2174 "the remote is not a forge, so there is no pull request".to_owned(),
2175 ));
2176 }
2177 None => match find_open_pr(&repo, branch, &base).await {
2178 Err(e) if forge_unavailable(&format!("{e:#}")) => {
2179 return Ok(Err(format!("no forge to ask: {e:#}")));
2180 }
2181 Err(e) => return Err(e),
2182 Ok(OpenPr::One { url, .. }) => url,
2183 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2184 Ok(OpenPr::Many(urls)) => {
2185 return Ok(Err(format!(
2186 "{} open pull requests name it; not choosing between them",
2187 urls.len()
2188 )));
2189 }
2190 },
2191 };
2192 let (ok, view) = gh(
2193 &repo,
2194 &[
2195 "pr".to_owned(),
2196 "view".to_owned(),
2197 url.clone(),
2198 "--json".to_owned(),
2199 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2200 ],
2201 )
2202 .await?;
2203 if !ok {
2204 bail!("gh pr view {url} failed: {view}");
2205 }
2206 if let Err(refusal) = closable(&view, branch, &base, verified) {
2207 if refusal.retry {
2210 bail!("left {url} open: {}", refusal.why);
2211 }
2212 return Ok(Err(format!("left {url} open: {}", refusal.why)));
2213 }
2214 let (ok, out) = gh(
2215 &repo,
2216 &[
2217 "pr".to_owned(),
2218 "close".to_owned(),
2219 url.clone(),
2220 "--comment".to_owned(),
2221 superseded_comment(&base, evidence),
2222 ],
2223 )
2224 .await?;
2225 if !ok {
2226 bail!("gh pr close {url} failed: {out}");
2227 }
2228 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2229 p.state = "closed".to_owned();
2230 }
2231 Ok(Ok(url))
2232}
2233
2234pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2238 let (title, _) = crate::github_text::prepare(state, title, "");
2239 edit_pr_title(repo, url, &title).await
2240}
2241
2242pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2246 edit_pr_title(repo, url, title).await
2247}
2248
2249pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2252 let (ok, _) = gh(
2253 repo,
2254 &[
2255 "pr".to_owned(),
2256 "edit".to_owned(),
2257 url.to_owned(),
2258 "--title".to_owned(),
2259 title.to_owned(),
2260 "--body".to_owned(),
2261 body.to_owned(),
2262 ],
2263 )
2264 .await
2265 .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2266 if !ok {
2267 bail!("gh pr edit failed while posting the chosen rewrite");
2269 }
2270 Ok(())
2271}
2272
2273async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2274 let (ok, out) = gh(
2275 repo,
2276 &[
2277 "pr".to_owned(),
2278 "edit".to_owned(),
2279 url.to_owned(),
2280 "--title".to_owned(),
2281 title.to_owned(),
2282 ],
2283 )
2284 .await?;
2285 if !ok {
2286 bail!("gh pr edit failed: {out}");
2287 }
2288 Ok(())
2289}
2290
2291pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2304 let Some(winner) = state.winner() else {
2305 return Ok(None);
2306 };
2307 let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2308 let out = gh(
2309 &state.repo,
2310 &[
2311 "pr".to_owned(),
2312 "list".to_owned(),
2313 "--head".to_owned(),
2314 branch.clone(),
2315 "--state".to_owned(),
2316 "merged".to_owned(),
2317 "--json".to_owned(),
2318 "url,number,mergedAt,baseRefName".to_owned(),
2319 ],
2320 )
2321 .await?;
2322 if !out.0 {
2323 bail!("gh pr list --head {branch}: {}", out.1);
2324 }
2325 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2326}
2327
2328pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2340 let out = tokio::process::Command::new("git")
2341 .args(["merge-base", "--is-ancestor", branch, base_branch])
2342 .current_dir(repo)
2343 .quiet()
2344 .stdin(std::process::Stdio::null())
2345 .output()
2346 .await
2347 .context("spawn git merge-base --is-ancestor")?;
2348 Ok(out.status.success())
2349}
2350
2351fn forge_slug(url: &str) -> Option<(String, &str)> {
2359 let rest = url.rsplit("://").next()?;
2360 let (host, path) = rest.split_once('/')?;
2361 if host.is_empty() {
2362 return None;
2363 }
2364 Some((host.to_ascii_lowercase(), path))
2365}
2366
2367pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2375 let (host, path) = forge_slug(url)?;
2376 let mut segments = path.split('/');
2377 let owner = segments.next()?;
2378 let repo = segments.next()?;
2379 let kind = segments.next()?;
2380 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2381 return None;
2382 }
2383 Some(format!("{host}/{owner}/{repo}"))
2384}
2385
2386fn slug_of_repo_url(url: &str) -> Option<String> {
2390 let (host, path) = forge_slug(url)?;
2391 let mut segments = path.split('/');
2392 let owner = segments.next()?;
2393 let repo = segments.next()?;
2394 if owner.is_empty() || repo.is_empty() {
2395 return None;
2396 }
2397 Some(format!("{host}/{owner}/{repo}"))
2398}
2399
2400pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2421 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2422 return Ok(());
2423 }
2424 bail!(
2425 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2426 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2427 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2428 verify by hand before treating this as a false positive)"
2429 );
2430}
2431
2432async fn repo_slug(repo: &Path) -> Result<String> {
2443 let out = gh(
2444 repo,
2445 &[
2446 "repo".to_owned(),
2447 "view".to_owned(),
2448 "--json".to_owned(),
2449 "url".to_owned(),
2450 ],
2451 )
2452 .await?;
2453 if !out.0 {
2454 bail!("gh repo view --json url: {}", out.1);
2455 }
2456 #[derive(Debug, Deserialize)]
2457 struct GhRepo {
2458 url: String,
2459 }
2460 let parsed: GhRepo = serde_json::from_str(&out.1)
2461 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2462 slug_of_repo_url(&parsed.url)
2463 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2464}
2465
2466pub async fn correct_manual_merge(
2514 state: &mut RunState,
2515 url: &str,
2516) -> Result<(RunStatus, RunStatus)> {
2517 let Some(pr_slug) = slug_of_pr_url(url) else {
2518 bail!(
2519 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2520 this pull request belongs to"
2521 );
2522 };
2523 let run_slug = repo_slug(&state.repo).await?;
2524 ensure_same_repo(&run_slug, &pr_slug)?;
2525 correct_merge(state, url).await
2526}
2527
2528pub(crate) async fn correct_confirmed_external_merge(
2539 state: &mut RunState,
2540 url: &str,
2541) -> Result<(RunStatus, RunStatus)> {
2542 correct_merge(state, url).await
2543}
2544
2545fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2547 let Some(pr) = a.pr.as_ref() else {
2548 return false;
2549 };
2550 if !url.is_empty()
2551 && pr
2552 .url
2553 .trim_end_matches('/')
2554 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2555 {
2556 return true;
2557 }
2558 number > 0
2559 && pr.number == number
2560 && match (a.repo.canonicalize(), repo.canonicalize()) {
2561 (Ok(x), Ok(y)) => x == y,
2562 _ => a.repo == repo,
2563 }
2564}
2565
2566fn rewrite_open_prs(
2579 home: &Path,
2580 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2581) -> usize {
2582 let now = Timestamp::now();
2583 let mut changed = 0;
2584 for id in crate::run::list_ids_in(&home.join("runs")) {
2585 let path = home.join("runs").join(&id).join("run.json");
2586 let Ok(body) = std::fs::read_to_string(&path) else {
2587 continue;
2588 };
2589 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2590 continue;
2591 };
2592 if !state.status.done()
2593 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2594 || crate::daemon::is_working_on(home, &id, now)
2595 {
2596 continue;
2597 }
2598 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2599 continue;
2600 };
2601 if let Some(pr) = state.pr.as_mut() {
2602 pr.state = to.as_str().to_owned();
2603 }
2604 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2605 state.event(
2606 "land",
2607 format!("recorded {url} as {}: another run settled it", to.as_str()),
2608 );
2609 match state.save_under(home) {
2610 Ok(()) => changed += 1,
2611 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2612 }
2613 }
2614 changed
2615}
2616
2617pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2622 if to == PrLifecycle::Open {
2623 return;
2624 }
2625 let Some(home) = crate::run::try_home() else {
2626 return;
2627 };
2628 write_pr_state_through_in(&home, run, to);
2629}
2630
2631pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2632 let Some(pr) = run.pr.as_ref() else {
2633 return 0;
2634 };
2635 let (url, number) = (pr.url.clone(), pr.number);
2636 rewrite_open_prs(home, &mut |other| {
2637 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2638 })
2639}
2640
2641pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2644 let now = Timestamp::now();
2645 let mut out = Vec::new();
2646 for id in crate::run::list_ids_in(&home.join("runs")) {
2647 let path = home.join("runs").join(&id).join("run.json");
2648 let Ok(body) = std::fs::read_to_string(&path) else {
2649 continue;
2650 };
2651 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2652 continue;
2653 };
2654 if let Some(pr) = state.pr.as_ref()
2655 && state.status.done()
2656 && pr.state == "open"
2657 && !pr.url.is_empty()
2658 && !crate::daemon::is_working_on(home, &id, now)
2659 {
2660 out.push((id, state.repo.clone(), pr.url.clone()));
2661 }
2662 }
2663 out
2664}
2665
2666pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2669 rewrite_open_prs(home, &mut |s| {
2670 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2671 })
2672}
2673
2674pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2681 let mut known = BTreeMap::new();
2682 let mut failed = 0;
2683 let mut seen = BTreeSet::new();
2684 for (_, repo, url) in stale_open_prs(home) {
2685 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2686 continue;
2687 }
2688 match lifecycle(&repo, &url).await {
2689 Ok(state) => {
2690 known.insert(url, state);
2691 }
2692 Err(e) => {
2693 tracing::warn!("repair pr state of {url}: {e:#}");
2694 failed += 1;
2695 }
2696 }
2697 }
2698 (apply_pr_states(home, &known), failed)
2699}
2700
2701async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2702 match lifecycle(&state.repo, url).await? {
2703 PrLifecycle::Merged => {}
2704 other => bail!(
2705 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2706 other.as_str(),
2707 state.id
2708 ),
2709 }
2710 let before = state.status;
2711 if let Err(e) = land(state, url).await {
2712 state.status = RunStatus::Blocked;
2719 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2720 state.save()?;
2721 return Err(e).context(format!("confirming the merge of {url}"));
2722 }
2723 state.event(
2724 "fold",
2725 "operator recorded this pull request as a manual merge; this run never \
2726 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2727 bump this change might warrant has to be filed by hand",
2728 );
2729 if state.status == RunStatus::Merged {
2732 crate::followup::after_merge(state, url).await;
2733 }
2734 state.save()?;
2735 Ok((before, state.status))
2736}
2737
2738pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2745 let raw: Vec<GhInline> =
2746 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2747 let mut out = Vec::new();
2748 for c in raw {
2749 push_if_outstanding(
2750 &mut out,
2751 ReviewComment {
2752 author: c.user.login,
2753 path: c.path,
2754 line: c.line,
2755 body: c.body,
2756 },
2757 );
2758 }
2759 Ok(out)
2760}
2761
2762fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2768 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2769 return;
2770 }
2771 if comment.path.is_none() && is_noise(&comment.body) {
2772 return;
2773 }
2774 out.push(comment);
2775}
2776
2777pub fn is_noise(body: &str) -> bool {
2795 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2796 return true;
2797 }
2798 let mut content = false;
2799 for line in strip_blocks(body).lines() {
2800 let line = unquote(line);
2801 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2802 continue;
2803 }
2804 content = true;
2805 break;
2806 }
2807 !content
2808}
2809
2810fn strip_blocks(body: &str) -> String {
2812 let mut out = String::with_capacity(body.len());
2813 let mut rest = body;
2814 loop {
2815 let open = ["<!--", "<details>"]
2816 .iter()
2817 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2818 .min_by_key(|(i, _)| *i);
2819 let Some((at, tag)) = open else {
2820 out.push_str(rest);
2821 return out;
2822 };
2823 out.push_str(&rest[..at]);
2824 let after = &rest[at + tag.len()..];
2825 let close = if tag == "<!--" { "-->" } else { "</details>" };
2826 match after.find(close) {
2827 Some(end) => rest = &after[end + close.len()..],
2828 None => return out,
2830 }
2831 }
2832}
2833
2834fn unquote(line: &str) -> &str {
2836 let mut s = line.trim();
2837 while let Some(rest) = s.strip_prefix('>') {
2838 s = rest.trim_start();
2839 }
2840 s.trim()
2841}
2842
2843fn is_checklist(line: &str) -> bool {
2845 let rest = line
2846 .strip_prefix("- ")
2847 .or_else(|| line.strip_prefix("* "))
2848 .unwrap_or("");
2849 let rest = rest.trim_start();
2850 matches!(
2851 rest.get(..3),
2852 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2853 )
2854}
2855
2856fn is_decoration(line: &str) -> bool {
2858 line.starts_with('#')
2859 || line.starts_with("[!")
2860 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2861}
2862
2863fn is_banner(line: &str) -> bool {
2870 let plain = drop_spans(line, "**", "**");
2871 let plain = if plain.contains("](") {
2872 drop_spans(&plain, "[", ")")
2873 } else {
2874 plain
2875 };
2876 !plain.chars().any(char::is_alphanumeric)
2877}
2878
2879fn drop_spans(s: &str, open: &str, close: &str) -> String {
2883 let mut out = String::with_capacity(s.len());
2884 let mut rest = s;
2885 while let Some(at) = rest.find(open) {
2886 out.push_str(&rest[..at]);
2887 let after = &rest[at + open.len()..];
2888 match after.find(close) {
2889 Some(end) => rest = &after[end + close.len()..],
2890 None => return out,
2891 }
2892 }
2893 out.push_str(rest);
2894 out
2895}
2896
2897fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2915 static LOCKS: std::sync::LazyLock<
2916 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2917 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2918 LOCKS
2919 .lock()
2920 .unwrap_or_else(std::sync::PoisonError::into_inner)
2921 .entry(repo.to_path_buf())
2922 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2923 .clone()
2924}
2925
2926fn repo_label(repo: &Path, pr_url: &str) -> String {
2929 let parts: Vec<&str> = pr_url.split('/').collect();
2930 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2931 && at >= 2
2932 && !parts[at - 1].is_empty()
2933 && !parts[at - 2].is_empty()
2934 {
2935 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2936 }
2937 repo.file_name()
2938 .map(|n| n.to_string_lossy().into_owned())
2939 .unwrap_or_default()
2940}
2941
2942fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2946 (pr.checks == Checks::Red).then(|| {
2947 format!(
2948 "Merged {repo_name} PR #{} with red checks: {} ({})",
2949 pr.number,
2950 if pr.failing.is_empty() {
2951 "(none named)".to_owned()
2952 } else {
2953 pr.failing.join(", ")
2954 },
2955 pr.url
2956 )
2957 })
2958}
2959
2960async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2964 let repo_name = repo_label(&state.repo, &pr.url);
2965 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2966 return;
2967 };
2968 if let Some(rec) = state.pr.as_mut() {
2969 rec.red_at_merge = pr.failing.clone();
2970 }
2971 state.event("land", summary.clone());
2972 crate::notices::raise_with(
2975 crate::notices::merged_red(&state.id, &summary),
2976 &state.config.notify,
2977 );
2978}
2979
2980pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2987 land_with(state, pr_url, &GhForge).await
2988}
2989
2990trait Forge {
2994 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2995 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2996 async fn poll(&self);
2997 async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
2999 fn now(&self) -> i64;
3001 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3004 #[allow(clippy::too_many_arguments)]
3005 async fn fix(
3006 &self,
3007 state: &mut RunState,
3008 pr: &PrState,
3009 round: usize,
3010 budget: usize,
3011 reason: &str,
3012 logs: &str,
3013 ) -> Result<Fixed>;
3014}
3015
3016struct GhForge;
3017
3018impl Forge for GhForge {
3019 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3020 observe(repo, pr_url).await
3021 }
3022 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3023 gh(repo, argv).await
3024 }
3025 async fn poll(&self) {
3026 tokio::time::sleep(POLL).await;
3027 }
3028 async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3029 let args = [
3030 "run".to_owned(),
3031 "rerun".to_owned(),
3032 run.to_owned(),
3033 "--failed".to_owned(),
3034 ];
3035 let (ok, out) = gh(repo, &args).await?;
3036 if !ok {
3037 bail!("gh run rerun {run}: {out}");
3038 }
3039 Ok(())
3040 }
3041 fn now(&self) -> i64 {
3042 jiff::Timestamp::now().as_second()
3043 }
3044 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3045 required_contexts_of(repo, base).await
3046 }
3047 async fn fix(
3048 &self,
3049 state: &mut RunState,
3050 pr: &PrState,
3051 round: usize,
3052 budget: usize,
3053 reason: &str,
3054 logs: &str,
3055 ) -> Result<Fixed> {
3056 fix_round(state, pr, round, budget, reason, logs).await
3057 }
3058}
3059
3060fn encode_path_segment(s: &str) -> String {
3062 let mut out = String::new();
3063 for b in s.bytes() {
3064 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3065 out.push(b as char);
3066 } else {
3067 let _ = write!(out, "%{b:02X}");
3068 }
3069 }
3070 out
3071}
3072
3073async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3078 let enc = encode_path_segment(base);
3079 let mut all = BTreeSet::new();
3080 let classic = gh(
3083 repo,
3084 &[
3085 "api".to_owned(),
3086 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3087 ],
3088 )
3089 .await
3090 .ok()?;
3091 if classic.0 {
3092 all.extend(parse_classic_required(&classic.1)?);
3093 } else if !classic.1.contains("404") {
3094 return None;
3095 }
3096 let rules = gh(
3097 repo,
3098 &[
3099 "api".to_owned(),
3100 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3101 ],
3102 )
3103 .await
3104 .ok()?;
3105 if !rules.0 {
3106 return None;
3107 }
3108 all.extend(parse_ruleset_required(&rules.1)?);
3109 Some(all)
3110}
3111
3112fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3115 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3116 let mut out = BTreeSet::new();
3117 for c in v.get("contexts")?.as_array()? {
3118 out.insert(c.as_str()?.to_owned());
3119 }
3120 for c in v
3121 .get("checks")
3122 .and_then(|c| c.as_array())
3123 .into_iter()
3124 .flatten()
3125 {
3126 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3127 out.insert(name.to_owned());
3128 }
3129 }
3130 Some(out)
3131}
3132
3133fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3135 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3136 let mut out = BTreeSet::new();
3137 for rule in v.as_array()? {
3138 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3139 continue;
3140 }
3141 let checks = rule
3142 .pointer("/parameters/required_status_checks")?
3143 .as_array()?;
3144 for c in checks {
3145 out.insert(c.get("context")?.as_str()?.to_owned());
3146 }
3147 }
3148 Some(out)
3149}
3150
3151fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3159 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3160}
3161
3162fn bound_head<'a>(
3172 seen_head: &'a str,
3173 rollup_head: &str,
3174 awaiting: Option<&str>,
3175) -> Option<&'a str> {
3176 if seen_head.is_empty()
3177 || awaiting_new_head(awaiting, seen_head)
3178 || !rollup_head.eq_ignore_ascii_case(seen_head)
3179 {
3180 return None;
3181 }
3182 Some(seen_head)
3183}
3184
3185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3187enum Refused {
3188 Pending,
3190 Recheck,
3194 Final,
3197}
3198
3199fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3202 let Some(after) = after else {
3203 return Refused::Pending;
3205 };
3206 if after.pr.state != PrLifecycle::Open {
3207 return Refused::Final;
3208 }
3209 if !after.head.eq_ignore_ascii_case(observed_head) {
3212 return Refused::Pending;
3213 }
3214 if bound_head(&after.head, &after.rollup_head, None).is_none() {
3216 return Refused::Pending;
3217 }
3218 let state = after.merge_state.to_ascii_uppercase();
3219 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3220 || state.is_empty()
3221 || state == "UNKNOWN"
3222 {
3223 return Refused::Pending;
3224 }
3225 if rechecked {
3226 Refused::Final
3227 } else {
3228 Refused::Recheck
3229 }
3230}
3231
3232async fn disarm<F: Forge>(
3237 forge: &F,
3238 state: &mut RunState,
3239 repo: &Path,
3240 number: u64,
3241) -> std::result::Result<(), String> {
3242 let argv = disable_automerge_argv(number);
3243 let out = {
3244 let merge_lock = repo_merge_lock(repo);
3245 let _merge_slot = merge_lock.lock().await;
3246 forge.merge(repo, &argv).await
3247 };
3248 match out {
3249 Ok((true, _)) => {
3250 state.land_armed_head = None;
3251 state.event("land", "auto-merge disabled");
3252 state.save().map_err(|e| format!("{e:#}"))?;
3253 Ok(())
3254 }
3255 Ok((false, msg)) => Err(msg),
3256 Err(e) => Err(format!("{e:#}")),
3257 }
3258}
3259
3260async fn stop_disarmed<F: Forge>(
3264 forge: &F,
3265 state: &mut RunState,
3266 repo: &Path,
3267 pr: &PrState,
3268 why: &str,
3269) -> Result<()> {
3270 if state.land_armed_head.is_none() {
3271 return stop(state, repo, pr, why).await;
3272 }
3273 match disarm(forge, state, repo, pr.number).await {
3274 Ok(()) => stop(state, repo, pr, why).await,
3275 Err(e) => {
3276 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3277 stop(state, repo, pr, &why).await
3278 }
3279 }
3280}
3281
3282async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3283 crate::pr_head::ensure_owner(state)?;
3284 let repo = state.repo.clone();
3285 let budget = state.config.graph.land_rounds;
3286 let mut round = 0usize;
3287 let mut rebases = 0usize;
3290 let mut waited = Duration::ZERO;
3291 let mut shown: BTreeSet<String> = BTreeSet::new();
3296 let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3298 let mut rechecked = false;
3300
3301 state.status = RunStatus::Landing;
3309 state.event("land", format!("watching {pr_url}"));
3310 state.save()?;
3311
3312 let mut resumed_armed = state.land_armed_head.is_some();
3316
3317 loop {
3318 let seen = forge.view(&repo, pr_url).await?;
3319 let mut pr = seen.pr.clone();
3320 pr.review_comments.retain(|c| !shown.contains(&c.body));
3321 state.pr = Some(crate::run::PrRecord {
3322 url: pr.url.clone(),
3323 number: pr.number,
3324 state: pr.state.as_str().to_owned(),
3325 checks: pr.checks.as_str().to_owned(),
3326 round,
3327 rounds: budget,
3328 red_at_merge: Vec::new(),
3329 });
3330 state.save()?;
3331
3332 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3333 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3338 let why = format!(
3339 "a previous pass may have armed auto-merge and it could not be disabled \
3340 on resume: {e}"
3341 );
3342 stop(state, &repo, &pr, &why).await?;
3343 return Ok(pr);
3344 }
3345 }
3346
3347 if pr.state == PrLifecycle::Open
3352 && !seen.head.is_empty()
3353 && state
3354 .land_armed_head
3355 .as_deref()
3356 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3357 {
3358 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3359 let why = format!(
3360 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3361 );
3362 stop(state, &repo, &pr, &why).await?;
3363 return Ok(pr);
3364 }
3365 }
3366
3367 if pr.state == PrLifecycle::Open {
3368 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3369 if waited >= WAIT_CEILING {
3370 let want = awaiting_head.as_deref().unwrap_or_default();
3371 let why = format!(
3372 "the pull request's checks were still not about one readable head after \
3373 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3374 someone may have pushed over it",
3375 WAIT_CEILING.as_secs() / 60,
3376 if want.is_empty() { "any" } else { want },
3377 if seen.head.is_empty() {
3378 "nothing readable"
3379 } else {
3380 &seen.head
3381 },
3382 if seen.rollup_head.is_empty() {
3383 "nothing readable"
3384 } else {
3385 &seen.rollup_head
3386 },
3387 );
3388 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3389 return Ok(pr);
3390 }
3391 waited += POLL;
3392 forge.poll().await;
3393 continue;
3394 }
3395 if awaiting_head.take().is_some() {
3398 waited = Duration::ZERO;
3401 }
3402 }
3403
3404 if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3409 let failing = rerunnable(&seen.contexts);
3410 let now = forge.now();
3411 match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3412 RerunStep::Rerun(runs) => {
3413 let rec = state.land_reruns.get_or_insert_with(Default::default);
3414 if !rec.head.eq_ignore_ascii_case(&seen.head) {
3415 *rec = LandReruns {
3416 head: seen.head.clone(),
3417 runs: BTreeMap::new(),
3418 };
3419 }
3420 for run in &runs {
3421 rec.runs.insert(run.clone(), now);
3422 }
3423 state.save()?;
3427 let mut any = false;
3428 for run in &runs {
3429 let names = failing
3430 .iter()
3431 .filter(|(_, r)| r.as_deref() == Some(run))
3432 .map(|(l, _)| l.as_str())
3433 .collect::<Vec<_>>()
3434 .join(", ");
3435 match forge.rerun(&repo, run).await {
3436 Ok(()) => {
3437 any = true;
3438 state.event(
3439 "land",
3440 format!("reran failed check(s) {names} (run {run})"),
3441 );
3442 }
3443 Err(e) => state.event(
3445 "land",
3446 format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3447 ),
3448 }
3449 }
3450 state.save()?;
3451 if any {
3452 waited = Duration::ZERO;
3453 forge.poll().await;
3454 continue;
3455 }
3456 }
3457 RerunStep::Grace => {
3458 if waited >= WAIT_CEILING {
3459 let why = format!(
3460 "checks were still failing after a rerun and {} minutes",
3461 WAIT_CEILING.as_secs() / 60
3462 );
3463 stop(state, &repo, &pr, &why).await?;
3464 return Ok(pr);
3465 }
3466 waited += POLL;
3467 forge.poll().await;
3468 continue;
3469 }
3470 RerunStep::Fix => {}
3471 }
3472 }
3473
3474 let step = decide(&pr, round, budget, waited);
3475 let armed_here = state
3480 .land_armed_head
3481 .as_deref()
3482 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3483 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3484 if waited >= WAIT_CEILING {
3485 let required = if seen.base.is_empty() {
3486 None
3487 } else {
3488 forge.required_contexts(&repo, &seen.base).await
3489 };
3490 let why = format!(
3491 "auto-merge was armed on {} but the pull request did not merge within {} \
3492 minutes ({})",
3493 seen.head,
3494 WAIT_CEILING.as_secs() / 60,
3495 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3496 );
3497 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3498 return Ok(pr);
3499 }
3500 waited += POLL;
3501 forge.poll().await;
3502 continue;
3503 }
3504 if armed_here && !matches!(step, Step::Done { .. }) {
3505 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3508 let why = format!("auto-merge could not be disabled: {e}");
3509 stop(state, &repo, &pr, &why).await?;
3510 return Ok(pr);
3511 }
3512 }
3513 match step {
3514 Step::Wait => {
3515 if waited >= WAIT_CEILING {
3516 let why = format!(
3517 "checks were still running after {} minutes",
3518 WAIT_CEILING.as_secs() / 60
3519 );
3520 stop(state, &repo, &pr, &why).await?;
3521 return Ok(pr);
3522 }
3523 waited += POLL;
3524 forge.poll().await;
3525 }
3526 Step::Done { merged } => {
3527 if let Some(armed) = state.land_armed_head.take() {
3532 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3533 let msg = format!(
3534 "{} merged on {} but the owner approved {armed}; review what landed",
3535 pr.url, seen.head
3536 );
3537 tracing::warn!("{msg}");
3538 state.event("land", msg);
3539 crate::notices::raise_with(
3543 crate::notices::Notice::warn(
3544 &format!("merged-unapproved-head:{}", state.id),
3545 "A pull request merged on a commit the owner did not approve; \
3546 review what landed",
3547 )
3548 .link(crate::notices::Link::Run {
3549 id: state.id.clone(),
3550 }),
3551 &state.config.notify,
3552 );
3553 }
3554 }
3555 state.status = if merged {
3556 RunStatus::Merged
3557 } else {
3558 RunStatus::Ready
3559 };
3560 let detail = if merged {
3561 format!("{} was merged", pr.url)
3562 } else {
3563 format!("{} was closed without merging", pr.url)
3564 };
3565 state.merge = Some(MergeOutcome {
3566 mode: MergeMode::Pr,
3567 ok: merged,
3568 detail: detail.clone(),
3569 empty: false,
3570 });
3571 state.event("land", detail);
3572 state.save()?;
3573 write_pr_state_through(state, pr.state);
3574 return Ok(pr);
3575 }
3576 Step::Merge => {
3577 let subject = merge_subject(
3578 crate::graph::landing_title(state, &seen.title),
3579 &crate::graph::landing_subject_source(state),
3580 );
3581 let contested = contested_to_ask(state);
3588 if state.config.graph.land_approval
3589 || contested.is_some()
3590 || repair_available(state)
3591 || !state.merge_repairs.is_empty()
3592 {
3593 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3594 .await?
3595 {
3596 ApprovalGate::Approved => {}
3597 ApprovalGate::Repair => {
3598 if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3601 state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3602 state.save()?;
3603 }
3604 return Ok(pr);
3605 }
3606 ApprovalGate::Held => {
3607 stop(
3608 state,
3609 &repo,
3610 &pr,
3611 "the owner did not approve the merge (held or unanswered)",
3612 )
3613 .await?;
3614 return Ok(pr);
3615 }
3616 ApprovalGate::Pending => {
3624 state.end_pass_parked(crate::run::ParkCause::Question);
3625 state.parked = true;
3626 state.event(
3627 "land",
3628 "parked awaiting merge approval - resumes once answered",
3629 );
3630 state.save()?;
3631 return Ok(pr);
3632 }
3633 }
3634 }
3635 let observed_head = seen.head.clone();
3646 {
3649 let fresh = forge.view(&repo, pr_url).await.ok();
3650 if !direct_merge_is_safe(
3651 fresh.as_ref(),
3652 &observed_head,
3653 &shown,
3654 round,
3655 budget,
3656 waited,
3657 ) {
3658 if waited >= WAIT_CEILING {
3659 let why = "the pull request did not settle on the approved head \
3660 before it could be merged";
3661 stop(state, &repo, &pr, why).await?;
3662 return Ok(pr);
3663 }
3664 state.event(
3665 "land",
3666 "the pull request changed before merging; looking again",
3667 );
3668 waited += POLL;
3669 forge.poll().await;
3670 continue;
3671 }
3672 }
3673 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3674 let out = {
3675 let merge_lock = repo_merge_lock(&repo);
3676 let _merge_slot = merge_lock.lock().await;
3677 forge.merge(&repo, &argv).await?
3678 };
3679 if out.0 {
3680 let confirmed = forge
3686 .view(&repo, pr_url)
3687 .await
3688 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3689 if !confirmed {
3690 if waited >= WAIT_CEILING {
3691 let why = "the merge request succeeded but the pull request \
3692 could not be confirmed merged after waiting";
3693 stop(state, &repo, &pr, why).await?;
3694 return Ok(pr);
3695 }
3696 state.event(
3697 "land",
3698 "merge accepted but the pull request is not confirmed merged yet; waiting",
3699 );
3700 state.save()?;
3701 waited += POLL;
3702 forge.poll().await;
3703 continue;
3704 }
3705 pr.state = PrLifecycle::Merged;
3706 state.status = RunStatus::Merged;
3707 state.merge = Some(MergeOutcome {
3708 mode: MergeMode::Pr,
3709 ok: true,
3710 detail: format!("gh {}", argv.join(" ")),
3711 empty: false,
3712 });
3713 if let Some(pr_record) = state.pr.as_mut() {
3718 pr_record.state = pr.state.as_str().to_owned();
3719 }
3720 state.event("land", format!("merged {} as `{subject}`", pr.url));
3721 announce_red_merge(state, &pr).await;
3722 state.save()?;
3723 write_pr_state_through(state, pr.state);
3724 return Ok(pr);
3725 }
3726 let after_seen = forge.view(&repo, pr_url).await.ok();
3727 let after = after_seen.as_ref().map(|s| s.pr.state);
3728 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3729 pr.state = PrLifecycle::Merged;
3730 state.status = RunStatus::Merged;
3731 state.merge = Some(outcome);
3732 if let Some(pr_record) = state.pr.as_mut() {
3733 pr_record.state = pr.state.as_str().to_owned();
3734 }
3735 state.event("land", format!("merged {} as `{subject}`", pr.url));
3736 announce_red_merge(state, &pr).await;
3737 state.save()?;
3738 write_pr_state_through(state, pr.state);
3739 return Ok(pr);
3740 }
3741 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3742 match verdict {
3743 Refused::Final => {
3744 let merge_state = after_seen
3745 .as_ref()
3746 .map(|s| s.merge_state.as_str())
3747 .filter(|m| !m.is_empty())
3748 .unwrap_or("unknown");
3749 let why = format!(
3753 "the merge was refused: {} (merge state: {merge_state})",
3754 out.1
3755 );
3756 stop(state, &repo, &pr, &why).await?;
3757 return Ok(pr);
3758 }
3759 verdict => {
3760 if waited >= WAIT_CEILING {
3765 let why = format!(
3766 "the merge was still refused after {} minutes: {}",
3767 WAIT_CEILING.as_secs() / 60,
3768 out.1
3769 );
3770 stop(state, &repo, &pr, &why).await?;
3771 return Ok(pr);
3772 }
3773 if verdict == Refused::Recheck {
3774 rechecked = true;
3775 }
3776 state.event(
3777 "land",
3778 "merge refused while the branch policy is not satisfied yet; waiting",
3779 );
3780 state.save()?;
3781 waited += POLL;
3782 forge.poll().await;
3783 }
3784 }
3785 }
3786 Step::Rebase => {
3787 if rebases >= budget {
3793 let why = format!(
3794 "the base moved under this branch {budget} time(s) and it still does \
3795 not merge; rebasing again would only race it"
3796 );
3797 stop(state, &repo, &pr, &why).await?;
3798 return Ok(pr);
3799 }
3800 rebases += 1;
3801 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3802 stop(
3803 state,
3804 &repo,
3805 &pr,
3806 "the pull request conflicts and this run has no winning branch to rebase",
3807 )
3808 .await?;
3809 return Ok(pr);
3810 };
3811 let base = state.base_branch.clone();
3812 state.event(
3813 "land",
3814 format!("{} no longer merges; rebasing onto {base}", pr.url),
3815 );
3816 state.save()?;
3817
3818 let remote = state.config.merge.remote.clone();
3822 git::fetch(&repo, &remote, &base).await.ok();
3823 let scratch = state.dir().join("rebase");
3824 let onto = format!("{remote}/{base}");
3825 let rebased =
3826 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3827 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3828 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3829 Err(e) => Err(e),
3830 };
3831 match rebased {
3832 Ok(None) => {
3833 let pushed = {
3834 let merge_lock = repo_merge_lock(&repo);
3835 let _merge_slot = merge_lock.lock().await;
3836 if let Some(head) = &state.pr_head {
3837 git::push_as(
3838 &repo,
3839 &state.config.merge.remote,
3840 &branch,
3841 head,
3842 state
3843 .pr_pushed_head
3844 .as_deref()
3845 .context("missing public head lease")?,
3846 )
3847 .await?
3848 } else {
3849 git::push_pinned(
3850 &repo,
3851 &state.config.merge.remote,
3852 &branch,
3853 &seen.head,
3854 )
3855 .await?
3856 }
3857 };
3858 if !pushed.ok() {
3859 let why = format!(
3860 "rebased {branch} but could not push it: {}",
3861 pushed.stderr.trim()
3862 );
3863 stop(state, &repo, &pr, &why).await?;
3864 return Ok(pr);
3865 }
3866 let head =
3869 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3870 Ok(head) => head,
3871 Err(e) => {
3872 let why = format!(
3873 "rebased and pushed {branch} but could not read the pushed \
3874 commit: {e:#}"
3875 );
3876 stop(state, &repo, &pr, &why).await?;
3877 return Ok(pr);
3878 }
3879 };
3880 crate::graph::refresh_reviewed_commits(state, &branch).await;
3881 state.pr_pushed_head = Some(head.clone());
3882 awaiting_head = Some(head);
3883 rechecked = false;
3884 state.event("land", format!("rebased {branch} onto {base}"));
3885 state.save()?;
3886 waited = Duration::ZERO;
3889 tokio::time::sleep(POLL).await;
3890 }
3891 Ok(Some(conflict)) => {
3894 let why = format!(
3895 "{} conflicts with {base} and the rebase did not apply: {}",
3896 pr.url,
3897 conflict.chars().take(600).collect::<String>()
3898 );
3899 stop(state, &repo, &pr, &why).await?;
3900 return Ok(pr);
3901 }
3902 Err(e) => {
3903 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3904 stop(state, &repo, &pr, &why).await?;
3905 return Ok(pr);
3906 }
3907 }
3908 }
3909 Step::GiveUp { reason } => {
3910 stop(state, &repo, &pr, &reason).await?;
3911 return Ok(pr);
3912 }
3913 Step::Fix { reason } => {
3914 round += 1;
3915 waited = Duration::ZERO;
3916 for c in &pr.review_comments {
3917 shown.insert(c.body.clone());
3918 }
3919 state.event("land", format!("round {round}: {reason}"));
3920 state.save()?;
3921
3922 let logs = failing_logs(&repo, &seen.failing_urls).await;
3923 let was_red = pr.checks == Checks::Red;
3924 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3925 Fixed::Committed { head } => {
3926 state.pr_pushed_head = Some(head.clone());
3929 awaiting_head = Some(head);
3930 rechecked = false;
3931 waited = Duration::ZERO;
3932 forge.poll().await;
3933 }
3934 Fixed::Declined if was_red => {
3935 let why = format!(
3936 "the fixer produced no commit while {} check(s) were failing \
3937 ({}); stopping instead of looping on an unchanged tree",
3938 pr.failing.len(),
3939 pr.failing.join(", ")
3940 );
3941 stop(state, &repo, &pr, &why).await?;
3942 return Ok(pr);
3943 }
3944 Fixed::Declined => state.event(
3949 "land",
3950 format!("round {round}: fixer declined the comments, nothing committed"),
3951 ),
3952 Fixed::Failed(why) => {
3953 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3954 return Ok(pr);
3955 }
3956 }
3957 state.save()?;
3958 }
3959 }
3960 }
3961}
3962
3963#[derive(Clone)]
3967struct Seen {
3968 pr: PrState,
3969 title: String,
3970 failing_urls: Vec<(String, String)>,
3971 head: String,
3973 rollup_head: String,
3976 merge_state: String,
3979 contexts: Vec<CheckInfo>,
3981 base: String,
3983}
3984
3985#[derive(Clone)]
3987struct CheckInfo {
3988 label: String,
3989 verdict: Verdict,
3990 required: Option<bool>,
3991 run: Option<String>,
3993}
3994
3995#[derive(Debug, Clone, PartialEq, Eq)]
3997enum RerunStep {
3998 Rerun(Vec<String>),
4000 Grace,
4002 Fix,
4004}
4005
4006fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4010 contexts
4011 .iter()
4012 .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4013 .filter(|c| {
4015 c.run.as_ref().is_none_or(|r| {
4016 !contexts.iter().any(|o| {
4017 matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4018 && o.run.as_ref() == Some(r)
4019 })
4020 })
4021 })
4022 .map(|c| (c.label.clone(), c.run.clone()))
4023 .collect()
4024}
4025
4026fn rerun_step(
4028 failing: &[(String, Option<String>)],
4029 record: Option<&LandReruns>,
4030 head: &str,
4031 now: i64,
4032) -> RerunStep {
4033 if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4036 return RerunStep::Fix;
4037 }
4038 let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4040 let mut fresh: Vec<String> = Vec::new();
4041 let mut recent = false;
4042 for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4043 match record.and_then(|r| r.runs.get(run)) {
4044 None => {
4045 if !fresh.iter().any(|f| f == run) {
4046 fresh.push(run.to_owned());
4047 }
4048 }
4049 Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4050 Some(_) => {}
4051 }
4052 }
4053 if !fresh.is_empty() {
4054 RerunStep::Rerun(fresh)
4055 } else if recent {
4056 RerunStep::Grace
4057 } else {
4058 RerunStep::Fix
4059 }
4060}
4061
4062async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4066 let view = gh(
4067 repo,
4068 &[
4069 "pr".to_owned(),
4070 "view".to_owned(),
4071 pr_url.to_owned(),
4072 "--json".to_owned(),
4073 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4074 .to_owned(),
4075 ],
4076 )
4077 .await?;
4078 if !view.0 {
4079 bail!("gh pr view {pr_url}: {}", view.1);
4080 }
4081 let number = parse_pr(&view.1)?.number;
4082 let node = last_commit_node(repo, number).await;
4083 let mut seen = seen_from(&view.1, node.as_deref())?;
4084
4085 let inline = gh(
4086 repo,
4087 &[
4088 "api".to_owned(),
4089 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4090 ],
4091 )
4092 .await?;
4093 if inline.0 {
4094 match parse_inline_comments(&inline.1) {
4095 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4096 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4099 }
4100 } else {
4101 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4102 }
4103 Ok(seen)
4104}
4105
4106fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4117 let mut pr = parse_pr(view_json)?;
4118 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4119
4120 let mut rollup_head = String::new();
4121 let mut failing_urls = Vec::new();
4122 let mut contexts = Vec::new();
4123 let mut checks = Checks::Unknown;
4124 let mut failing = Vec::new();
4125 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4126 (checks, failing) = rollup_verdict(&rollup);
4127 failing_urls = rollup
4128 .iter()
4129 .filter(|c| c.verdict() == Verdict::Fail)
4130 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4131 .collect();
4132 contexts = rollup
4133 .iter()
4134 .map(|c| CheckInfo {
4135 label: c.label(),
4136 verdict: c.verdict(),
4137 required: c.is_required,
4138 run: c.url().and_then(run_of),
4139 })
4140 .collect();
4141 rollup_head = oid;
4142 }
4143 pr.checks = checks;
4144 pr.failing = failing;
4145
4146 Ok(Seen {
4147 pr,
4148 title: raw.title,
4149 failing_urls,
4150 head: raw.head_ref_oid,
4151 rollup_head,
4152 merge_state: raw.merge_state_status,
4153 contexts,
4154 base: raw.base_ref_name,
4155 })
4156}
4157
4158fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4161 let v: serde_json::Value = serde_json::from_str(json).ok()?;
4162 if v.get("errors").is_some_and(|e| !e.is_null()) {
4163 return None;
4164 }
4165 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4166 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4167 let contexts = commit.pointer("/statusCheckRollup/contexts");
4168 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4169 return Some((oid.to_owned(), Vec::new()));
4171 };
4172 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4173 return None;
4174 }
4175 let nodes = contexts.get("nodes")?.as_array()?;
4176 let rollup = nodes
4177 .iter()
4178 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4179 .collect::<Result<Vec<_>, _>>()
4180 .ok()?;
4181 Some((oid.to_owned(), rollup))
4182}
4183
4184async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4187 let out = gh(
4188 repo,
4189 &[
4190 "api".to_owned(),
4191 "graphql".to_owned(),
4192 "-F".to_owned(),
4193 "owner={owner}".to_owned(),
4194 "-F".to_owned(),
4195 "repo={repo}".to_owned(),
4196 "-F".to_owned(),
4197 format!("number={number}"),
4198 "-f".to_owned(),
4199 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4200 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4201 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4202 ... on CheckRun{name status conclusion detailsUrl \
4203 isRequired(pullRequestNumber:$number)} \
4204 ... on StatusContext{context state targetUrl \
4205 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4206 .to_owned(),
4207 ],
4208 )
4209 .await
4210 .ok()?;
4211 out.0.then_some(out.1)
4212}
4213
4214#[doc(hidden)]
4216#[derive(Debug, PartialEq)]
4217pub enum Fixed {
4218 Committed {
4220 head: String,
4222 },
4223 Declined,
4225 Failed(String),
4227}
4228
4229#[doc(hidden)]
4235pub async fn fix_round(
4236 state: &mut RunState,
4237 pr: &PrState,
4238 round: usize,
4239 budget: usize,
4240 reason: &str,
4241 logs: &str,
4242) -> Result<Fixed> {
4243 let winner = state
4244 .winner()
4245 .cloned()
4246 .context("landing needs a winning candidate; none is recorded on this run")?;
4247 let roles = state
4248 .config
4249 .resolve_roles()
4250 .context("resolve the roster for the fix round")?;
4251 let attempts = crate::fixer::attempts(state, &roles, &winner);
4255 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4256
4257 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4258 let mut artifacts = agent::artifacts_dir(&state.dir());
4259 if let Some(request) = state
4260 .merge_repairs
4261 .last()
4262 .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4263 {
4264 artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4265 }
4266 let prompt = if state.config.cache_dir().is_some() {
4267 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4268 } else {
4269 prompt
4270 };
4271 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4275 let mut last = None;
4279 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4280 let mut seat = seat_of(state, &seat_key, &spec.id);
4281 let stem = if i == 0 {
4282 format!("land-{round}")
4283 } else {
4284 format!("land-{round}-{}", spec.id)
4285 };
4286 let out = agent::invoke(
4287 &spec,
4288 &mut seat,
4289 &Invocation {
4290 cwd: &winner.worktree,
4291 prompt: &prompt,
4292 timeout: Duration::from_secs(state.config.graph.timeout_fix),
4293 allow_write: true,
4294 unsandboxed: false,
4295 sessions: state.config.graph.sessions,
4296 artifacts: &artifacts,
4297 stem: &stem,
4298 run: &state.id,
4299 node: "land",
4300 cache_dir: state.config.cache_dir().as_deref(),
4301 attachments: &[],
4302 writable: &[],
4303 },
4304 )
4305 .await;
4306 state.seats.insert(seat.key.clone(), seat);
4307 if let Some(next) = ids.get(i + 1)
4308 && agent::chain_advances(&out)
4309 {
4310 let (class, why) = crate::fixer::failure_of(&out);
4311 crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4312 continue;
4313 }
4314 last = Some(out);
4315 break;
4316 }
4317 let out = last.expect("the fixer chain always has an entry");
4318
4319 match out {
4320 Ok(o) if o.quota_exhausted() => {
4321 return Ok(Fixed::Failed(
4322 "rate limited (quota); the fixer could not run".to_owned(),
4323 ));
4324 }
4325 Ok(o) if !o.usable() => {
4326 return Ok(Fixed::Failed(format!(
4327 "the fixer produced nothing usable (exit {:?}, timed out: {})",
4328 o.exit_code, o.timed_out
4329 )));
4330 }
4331 Ok(_) => {}
4332 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4333 }
4334
4335 match git::rescue_commit(
4338 &winner.worktree,
4339 &format!("magi: land round {round} fixes (uncommitted work)"),
4340 )
4341 .await
4342 {
4343 Ok(r) => state.note_withheld("land", &r.withheld),
4344 Err(e)
4345 if state
4346 .merge_repairs
4347 .last()
4348 .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4349 {
4350 return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4351 }
4352 Err(_) => {}
4353 }
4354 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4355 if after == before {
4356 return Ok(Fixed::Declined);
4357 }
4358
4359 let remote = state.config.merge.remote.clone();
4360 let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4361 let push = if state.pr_head.is_some() {
4362 let expected = state
4363 .pr_pushed_head
4364 .as_deref()
4365 .context("missing public head lease")?;
4366 git::push_as(
4367 &winner.worktree,
4368 &remote,
4369 &winner.branch,
4370 &public_head,
4371 expected,
4372 )
4373 .await?
4374 } else {
4375 git::push(&winner.worktree, &remote, &winner.branch).await?
4376 };
4377 if !push.ok() {
4378 return Ok(Fixed::Failed(format!(
4379 "pushing {} to {remote} failed: {}",
4380 public_head, push.stderr
4381 )));
4382 }
4383 state.event(
4384 "land",
4385 format!("round {round}: pushed a fix to {public_head}"),
4386 );
4387 state.pr_pushed_head = Some(after.clone());
4388 state.save()?;
4389 Ok(Fixed::Committed { head: after })
4390}
4391
4392pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4394 crate::fixer::seat_for(state, key, agent)
4395}
4396
4397pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4400 let seen = observe(&state.repo, url).await?;
4401 let winner = state.winner().context("no winning branch to repair")?;
4402 if seen.pr.state != PrLifecycle::Open || seen.head != head {
4403 anyhow::bail!(
4404 "PR is closed or its head changed; review the current head before requesting repair again"
4405 );
4406 }
4407 if git::rev_parse(&winner.worktree, "HEAD").await? != head
4408 || !git::is_clean(&winner.worktree).await?
4409 {
4410 anyhow::bail!(
4411 "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4412 );
4413 }
4414 let view = gh(
4417 &state.repo,
4418 &[
4419 "pr".into(),
4420 "view".into(),
4421 url.into(),
4422 "--json".into(),
4423 "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4424 ],
4425 )
4426 .await?;
4427 if !view.0 {
4428 bail!("cannot check auto-merge: {}", view.1);
4429 }
4430 let live: serde_json::Value = serde_json::from_str(&view.1)?;
4431 let public = state.pr_branch().unwrap_or(&winner.branch);
4432 if live["state"] != "OPEN"
4433 || live["headRefOid"] != head
4434 || live["headRefName"] != public
4435 || live["baseRefName"] != state.base_branch
4436 || live["isCrossRepository"] != false
4437 {
4438 bail!("PR changed during repair preflight; review its current head explicitly");
4439 }
4440 let auto_merge = live
4441 .get("autoMergeRequest")
4442 .context("cannot read the PR auto-merge reservation; repair held")?;
4443 if !auto_merge.is_null() {
4444 let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4445 if !out.0 {
4446 bail!("cannot disable auto-merge; repair held: {}", out.1);
4447 }
4448 }
4449 Ok(seen.pr)
4450}
4451
4452fn fix_prompt(
4454 state: &RunState,
4455 pr: &PrState,
4456 round: usize,
4457 budget: usize,
4458 reason: &str,
4459 logs: &str,
4460) -> String {
4461 let mut s = format!(
4462 "Your patch is open as a pull request and it is not landing. Land round \
4463 {round} of {budget}.\n\n\
4464 Pull request: {}\n\n\
4465 What is holding it: {reason}\n\n\
4466 # The task\n\n{}\n",
4467 pr.url, state.instruction
4468 );
4469
4470 if pr.failing.is_empty() {
4471 s.push_str("\n# Failing checks\n\n(none)\n");
4472 } else {
4473 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4474 if logs.trim().is_empty() {
4475 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4476 } else {
4477 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4478 }
4479 }
4480
4481 if pr.review_comments.is_empty() {
4482 s.push_str("\n# Review comments\n\n(none)\n");
4483 } else {
4484 s.push_str("\n# Review comments\n");
4485 for c in &pr.review_comments {
4486 let where_ = match (&c.path, c.line) {
4487 (Some(p), Some(l)) => format!(" ({p}:{l})"),
4488 (Some(p), None) => format!(" ({p})"),
4489 _ => String::new(),
4490 };
4491 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4492 }
4493 }
4494
4495 s.push_str(
4496 "\n# Rules\n\n\
4497 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4498 failing test; do not silence a lint with an allow attribute; do not \
4499 stretch a timeout to hide a race. If the check is right, the code is \
4500 wrong.\n\
4501 2. Change nothing the checks and the comments did not raise. A \
4502 drive-by refactor turns a one-line fix into a pull request that \
4503 needs reviewing again.\n\
4504 3. If a comment is wrong, say so with a checkable argument and change \
4505 nothing for it. A declined comment with a reason is a correct \
4506 outcome; a change made to appease a reviewer is not.\n\
4507 4. Commit in this worktree. magi pushes to the pull request's branch \
4508 for you; do not push, merge, or close anything yourself.\n\
4509 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4510 # Output\n\n\
4511 Say what you changed and why, and what you declined and why.",
4512 );
4513
4514 let language = &state.config.graph.language;
4515 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4516 let _ = write!(s, "\n\nWrite all prose in {language}.");
4517 }
4518 s.push_str(&crate::prompt::github_rule(
4520 language,
4521 &state.config.graph.github_language,
4522 ));
4523 if let Some(overlay) = state.config.prompts.overlay("fix") {
4524 let _ = write!(s, "\n\n{overlay}");
4525 }
4526 s
4527}
4528
4529async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4532 let mut out = String::new();
4533 for (name, url) in failing.iter().take(MAX_LOGS) {
4534 let args = match (job_of(url), run_of(url)) {
4535 (Some(job), _) => vec![
4536 "run".to_owned(),
4537 "view".to_owned(),
4538 "--log-failed".to_owned(),
4539 "--job".to_owned(),
4540 job,
4541 ],
4542 (None, Some(run)) => vec![
4543 "run".to_owned(),
4544 "view".to_owned(),
4545 run,
4546 "--log-failed".to_owned(),
4547 ],
4548 (None, None) => continue,
4550 };
4551 let (ok, body) = match gh(repo, &args).await {
4552 Ok(v) => v,
4553 Err(e) => (false, format!("{e:#}")),
4554 };
4555 if !ok && body.trim().is_empty() {
4556 continue;
4557 }
4558 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4559 }
4560 out
4561}
4562
4563fn job_of(details_url: &str) -> Option<String> {
4566 let after = details_url.split("/job/").nth(1)?;
4567 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4568 (!id.is_empty()).then_some(id)
4569}
4570
4571pub(crate) fn run_of(details_url: &str) -> Option<String> {
4573 let after = details_url.split("/actions/runs/").nth(1)?;
4574 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4575 (!id.is_empty()).then_some(id)
4576}
4577
4578fn stop_comment(run_id: &str, why: &str) -> String {
4582 format!(
4583 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4584 The branch is untouched and the run is `{run_id}`. Nothing was merged."
4585 )
4586}
4587
4588async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4593 let body = stop_comment(&state.id, why);
4594 let (_, mut body) = crate::github_text::prepare(state, "", &body);
4595 if !body.contains(MARKER) {
4596 body = format!("{MARKER}\n{body}");
4597 }
4598 let posted = gh(
4599 repo,
4600 &[
4601 "pr".to_owned(),
4602 "comment".to_owned(),
4603 pr.number.to_string(),
4604 "--body".to_owned(),
4605 body,
4606 ],
4607 )
4608 .await;
4609 match posted {
4610 Ok((true, _)) => {}
4611 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4612 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4613 }
4614 state.status = RunStatus::Blocked;
4615 state.merge = Some(MergeOutcome {
4616 mode: MergeMode::Pr,
4617 ok: false,
4618 detail: why.to_owned(),
4619 empty: false,
4620 });
4621 state.event("land", format!("stopped: {why}"));
4622 state.save()?;
4623 Ok(())
4624}
4625
4626pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4642 let out = tokio::process::Command::new("gh")
4643 .args(args)
4644 .current_dir(cwd)
4645 .env_remove("GH_REPO")
4646 .quiet()
4647 .stdin(std::process::Stdio::null())
4648 .output()
4649 .await
4650 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4651 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4652 let err = String::from_utf8_lossy(&out.stderr);
4653 if body.trim().is_empty() {
4654 body = err.into_owned();
4655 } else if !err.trim().is_empty() {
4656 body.push_str(&err);
4657 }
4658 Ok((out.status.success(), body.trim().to_owned()))
4659}
4660
4661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4663pub(crate) enum Verdict {
4664 Pass,
4665 Fail,
4666 Pending,
4667 Unknown,
4668}
4669
4670#[derive(Debug, Deserialize)]
4671#[serde(rename_all = "camelCase")]
4672struct GhPr {
4673 #[serde(default)]
4674 url: String,
4675 #[serde(default)]
4676 number: u64,
4677 #[serde(default)]
4678 state: String,
4679 #[serde(default)]
4680 title: String,
4681 #[serde(default)]
4682 status_check_rollup: Vec<GhCheck>,
4683 #[serde(default)]
4690 merge_state_status: String,
4691 #[serde(default)]
4695 head_ref_oid: String,
4696 #[serde(default)]
4697 base_ref_name: String,
4698 #[serde(default)]
4699 reviews: Vec<GhReview>,
4700 #[serde(default)]
4701 comments: Vec<GhComment>,
4702}
4703
4704#[derive(Debug, Deserialize)]
4709#[serde(rename_all = "camelCase")]
4710struct GhCheck {
4711 #[serde(default)]
4712 name: Option<String>,
4713 #[serde(default)]
4714 context: Option<String>,
4715 #[serde(default)]
4716 status: Option<String>,
4717 #[serde(default)]
4718 conclusion: Option<String>,
4719 #[serde(default)]
4720 state: Option<String>,
4721 #[serde(default)]
4722 details_url: Option<String>,
4723 #[serde(default)]
4724 target_url: Option<String>,
4725 #[serde(default)]
4728 is_required: Option<bool>,
4729}
4730
4731impl GhCheck {
4732 fn label(&self) -> String {
4734 self.name
4735 .clone()
4736 .or_else(|| self.context.clone())
4737 .unwrap_or_else(|| "(unnamed check)".to_owned())
4738 }
4739
4740 fn url(&self) -> Option<&str> {
4742 self.details_url
4743 .as_deref()
4744 .or(self.target_url.as_deref())
4745 .filter(|u| !u.is_empty())
4746 }
4747
4748 fn verdict(&self) -> Verdict {
4756 if let Some(status) = self.status.as_deref() {
4757 if !status.eq_ignore_ascii_case("COMPLETED") {
4758 return Verdict::Pending;
4759 }
4760 }
4761 let outcome = self
4762 .conclusion
4763 .as_deref()
4764 .or(self.state.as_deref())
4765 .unwrap_or("");
4766 match outcome.to_ascii_uppercase().as_str() {
4767 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4768 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4769 | "ACTION_REQUIRED" => Verdict::Fail,
4770 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4771 Verdict::Pending
4772 }
4773 _ => Verdict::Unknown,
4774 }
4775 }
4776}
4777
4778#[derive(Debug, Deserialize)]
4779struct GhAuthor {
4780 #[serde(default)]
4781 login: String,
4782}
4783
4784#[derive(Debug, Deserialize)]
4785struct GhReview {
4786 #[serde(default)]
4787 author: GhAuthor,
4788 #[serde(default)]
4789 body: String,
4790}
4791
4792#[derive(Debug, Deserialize)]
4793struct GhComment {
4794 #[serde(default)]
4795 author: GhAuthor,
4796 #[serde(default)]
4797 body: String,
4798}
4799
4800#[derive(Debug, Deserialize)]
4801struct GhUser {
4802 #[serde(default)]
4803 login: String,
4804}
4805
4806#[derive(Debug, Deserialize)]
4807struct GhInline {
4808 #[serde(default)]
4809 user: GhUser,
4810 #[serde(default)]
4811 path: Option<String>,
4812 #[serde(default)]
4813 line: Option<u64>,
4814 #[serde(default)]
4815 body: String,
4816}
4817
4818impl Default for GhAuthor {
4819 fn default() -> Self {
4820 Self {
4821 login: "(unknown)".to_owned(),
4822 }
4823 }
4824}
4825
4826impl Default for GhUser {
4827 fn default() -> Self {
4828 Self {
4829 login: "(unknown)".to_owned(),
4830 }
4831 }
4832}
4833
4834#[cfg(test)]
4835mod tests {
4836 use super::*;
4837 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4838
4839 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4840 format!(
4841 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4842 )
4843 }
4844
4845 #[test]
4846 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4847 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4848 assert_eq!(
4849 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4850 Ok(())
4851 );
4852 for (json, why) in [
4853 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4854 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4855 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4856 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4857 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4858 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4859 ] {
4860 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4861 .unwrap_err()
4862 .why;
4863 assert!(err.contains(why), "{json}: {err}");
4864 }
4865 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4867 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4868 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4869 assert!(
4870 !closable(
4871 &head_json("x", "main", "OPEN", false),
4872 "magi/27b2/A",
4873 "main",
4874 &[]
4875 )
4876 .unwrap_err()
4877 .retry
4878 );
4879 assert!(is_forge_url("https://github.com/o/r.git"));
4880 assert!(is_forge_url("git@github.com:o/r.git"));
4881 assert!(!is_forge_url("/tmp/origin.git"));
4882 assert!(!is_forge_url("C:\\work\\origin.git"));
4883 assert!(!is_forge_url("file:///tmp/origin.git"));
4884 assert!(forge_unavailable(
4885 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4886 ));
4887 assert!(!forge_unavailable(
4888 "gh pr list failed: error connecting to api.github.com"
4889 ));
4890 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4891 assert!(
4893 closable(
4894 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4895 "b",
4896 "main",
4897 &["aaa".to_owned()]
4898 )
4899 .is_err()
4900 );
4901 }
4902
4903 #[test]
4904 fn the_close_comment_names_the_commit_on_the_base() {
4905 let e = crate::already::Evidence {
4906 proof: crate::already::Proof::PatchId,
4907 tip: "1234567890".to_owned(),
4908 commits: vec!["0e368de0000".to_owned()],
4909 };
4910 let c = superseded_comment("main", &e);
4911 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4912 }
4913
4914 const GREEN_OPEN: &str = r####"{
4916 "url": "https://github.com/yukimemi/magi/pull/10",
4917 "number": 10,
4918 "state": "OPEN",
4919 "mergeStateStatus": "CLEAN",
4920 "statusCheckRollup": [
4921 {
4922 "__typename": "CheckRun",
4923 "conclusion": "SKIPPED",
4924 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4925 "name": "review",
4926 "status": "COMPLETED",
4927 "workflowName": "claude-review"
4928 },
4929 {
4930 "__typename": "CheckRun",
4931 "conclusion": "SUCCESS",
4932 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4933 "name": "check (ubuntu-latest)",
4934 "status": "COMPLETED",
4935 "workflowName": "CI"
4936 },
4937 {
4938 "__typename": "CheckRun",
4939 "conclusion": "SUCCESS",
4940 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4941 "name": "rustfmt",
4942 "status": "COMPLETED",
4943 "workflowName": "CI"
4944 },
4945 {
4946 "__typename": "StatusContext",
4947 "context": "CodeRabbit",
4948 "state": "SUCCESS",
4949 "targetUrl": ""
4950 }
4951 ],
4952 "reviews": [],
4953 "comments": [
4954 {
4955 "author": {
4956 "login": "coderabbitai"
4957 },
4958 "authorAssociation": "NONE",
4959 "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"
4960 }
4961 ]
4962}"####;
4963
4964 const RED_OPEN: &str = r####"{
4966 "url": "https://github.com/yukimemi/magi/pull/9",
4967 "number": 9,
4968 "state": "OPEN",
4969 "mergeStateStatus": "UNSTABLE",
4970 "statusCheckRollup": [
4971 {
4972 "__typename": "CheckRun",
4973 "conclusion": "SUCCESS",
4974 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4975 "name": "check (ubuntu-latest)",
4976 "status": "COMPLETED",
4977 "workflowName": "CI"
4978 },
4979 {
4980 "__typename": "CheckRun",
4981 "conclusion": "SUCCESS",
4982 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4983 "name": "rustfmt",
4984 "status": "COMPLETED",
4985 "workflowName": "CI"
4986 },
4987 {
4988 "__typename": "CheckRun",
4989 "conclusion": "FAILURE",
4990 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4991 "name": "editorconfig",
4992 "status": "COMPLETED",
4993 "workflowName": "CI"
4994 },
4995 {
4996 "__typename": "StatusContext",
4997 "context": "CodeRabbit",
4998 "state": "SUCCESS",
4999 "targetUrl": ""
5000 }
5001 ],
5002 "reviews": [],
5003 "comments": [
5004 {
5005 "author": {
5006 "login": "coderabbitai"
5007 },
5008 "authorAssociation": "NONE",
5009 "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"
5010 }
5011 ]
5012}"####;
5013
5014 const PENDING_OPEN: &str = r####"{
5016 "url": "https://github.com/yukimemi/magi/pull/9",
5017 "number": 9,
5018 "state": "OPEN",
5019 "statusCheckRollup": [
5020 {
5021 "__typename": "CheckRun",
5022 "conclusion": "SUCCESS",
5023 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5024 "name": "check (ubuntu-latest)",
5025 "status": "COMPLETED",
5026 "workflowName": "CI"
5027 },
5028 {
5029 "__typename": "CheckRun",
5030 "conclusion": "SUCCESS",
5031 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5032 "name": "rustfmt",
5033 "status": "COMPLETED",
5034 "workflowName": "CI"
5035 },
5036 {
5037 "__typename": "CheckRun",
5038 "conclusion": null,
5039 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5040 "name": "editorconfig",
5041 "status": "IN_PROGRESS",
5042 "workflowName": "CI"
5043 },
5044 {
5045 "__typename": "StatusContext",
5046 "context": "CodeRabbit",
5047 "state": "SUCCESS",
5048 "targetUrl": ""
5049 }
5050 ],
5051 "reviews": [],
5052 "comments": []
5053}"####;
5054
5055 const MERGED: &str = r####"{
5057 "url": "https://github.com/yukimemi/magi/pull/16",
5058 "number": 16,
5059 "state": "MERGED",
5060 "statusCheckRollup": [
5061 {
5062 "__typename": "CheckRun",
5063 "conclusion": "SUCCESS",
5064 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5065 "name": "check (ubuntu-latest)",
5066 "status": "COMPLETED",
5067 "workflowName": "CI"
5068 },
5069 {
5070 "__typename": "CheckRun",
5071 "conclusion": "SUCCESS",
5072 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5073 "name": "review",
5074 "status": "COMPLETED",
5075 "workflowName": "claude-review"
5076 }
5077 ],
5078 "reviews": [],
5079 "comments": []
5080}"####;
5081
5082 const REVIEWED_OPEN: &str = r####"{
5084 "url": "https://github.com/yukimemi/magi/pull/12",
5085 "number": 12,
5086 "state": "OPEN",
5087 "statusCheckRollup": [
5088 {
5089 "__typename": "CheckRun",
5090 "conclusion": "SUCCESS",
5091 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5092 "name": "check (ubuntu-latest)",
5093 "status": "COMPLETED",
5094 "workflowName": "CI"
5095 },
5096 {
5097 "__typename": "CheckRun",
5098 "conclusion": "SUCCESS",
5099 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5100 "name": "review",
5101 "status": "COMPLETED",
5102 "workflowName": "claude-review"
5103 }
5104 ],
5105 "reviews": [
5106 {
5107 "author": {
5108 "login": "claude"
5109 },
5110 "state": "COMMENTED",
5111 "body": ""
5112 }
5113 ],
5114 "comments": [
5115 {
5116 "author": {
5117 "login": "coderabbitai"
5118 },
5119 "authorAssociation": "NONE",
5120 "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"
5121 },
5122 {
5123 "author": {
5124 "login": "claude"
5125 },
5126 "authorAssociation": "NONE",
5127 "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"
5128 }
5129 ]
5130}"####;
5131
5132 const INLINE: &str = r####"[
5134 {
5135 "user": {
5136 "login": "claude[bot]"
5137 },
5138 "path": "src/graph.rs",
5139 "line": 231,
5140 "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"
5141 }
5142]"####;
5143
5144 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5146<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5147
5148> [!IMPORTANT]
5149> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5150>
5151> This repository does not receive automatic reviews because it has fewer than 10 stars.
5152>
5153> <details>
5154> <summary>⚙️ Run configuration</summary>
5155>
5156> **Configuration used**: defaults
5157>
5158> **Review profile**: CHILL
5159>
5160> **Plan**: Team
5161>
5162> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5163>
5164> </details>
5165
5166<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5167
5168<!-- tips_start -->
5169
5170---
5171
5172Thanks 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.
5173
5174<details>
5175<summary>❤️ Share</summary>
5176
5177- [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"####;
5178
5179 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5181
5182---
5183### Reviewing PR #16
5184
5185- [x] Read AGENTS.md conventions
5186- [x] Review `src/daemon.rs` changes
5187- [x] Review `src/main.rs` changes (new `doctor` reporting)
5188- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5189- [x] Check test coverage for new behavior
5190- [x] Run verification commands (blocked — see note)
5191- [x] Post findings"####;
5192
5193 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5195
5196---
5197### Review: `magi review <branch>` — cheap-half-only graph
5198
5199Read 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.
5200
5201**Correctness**
5202
5203- 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"####;
5204
5205 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5206 PrState {
5207 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5208 number: 16,
5209 state: PrLifecycle::Open,
5210 checks,
5211 blocking: if matches!(checks, Checks::Red) {
5215 Blocking::Yes
5216 } else {
5217 Blocking::No
5218 },
5219 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5220 review_comments: (0..comments)
5221 .map(|i| ReviewComment {
5222 author: "coderabbitai".to_owned(),
5223 path: Some("src/graph.rs".to_owned()),
5224 line: Some(231),
5225 body: format!("finding {i}"),
5226 })
5227 .collect(),
5228 }
5229 }
5230
5231 #[test]
5232 fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5233 use CiExpectation::{Absent, Expected};
5234 for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5235 let p = pr(checks, &["x"], 0);
5236 for waited in [Duration::ZERO, CHECKS_GRACE] {
5237 assert_eq!(
5238 decide_with(&p, 0, 4, waited, Expected),
5239 decide(&p, 0, 4, waited)
5240 );
5241 }
5242 }
5243 for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5245 let p = pr(checks, &["x"], 0);
5246 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5247 assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5248 }
5249 let mut p = pr(Checks::Unknown, &[], 0);
5251 p.blocking = Blocking::Conflict;
5252 assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5253 p.state = PrLifecycle::Merged;
5255 assert_eq!(
5256 decide_with(&p, 0, 4, Duration::ZERO, Absent),
5257 Step::Done { merged: true }
5258 );
5259 }
5260
5261 #[test]
5262 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5263 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5264 assert_eq!(state.number, 10);
5265 assert_eq!(state.state, PrLifecycle::Open);
5266 assert_eq!(state.checks, Checks::Green);
5267 assert!(state.failing.is_empty());
5268 assert!(
5269 state.review_comments.is_empty(),
5270 "the only comment is CodeRabbit's trigger notice: {:?}",
5271 state.review_comments
5272 );
5273 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5274 }
5275
5276 #[test]
5277 fn a_failing_check_parses_as_red_and_is_named() {
5278 let state = parse_pr(RED_OPEN).expect("red fixture parses");
5279 assert_eq!(state.checks, Checks::Red);
5280 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5281 let mut blocking = state.clone();
5288 blocking.blocking = Blocking::Yes;
5289 match decide(&blocking, 0, 4, Duration::ZERO) {
5290 Step::Fix { reason } => {
5291 assert!(reason.contains("editorconfig"), "reason: {reason}");
5292 assert!(reason.contains("failing"), "reason: {reason}");
5293 }
5294 other => panic!("expected a fix round, got {other:?}"),
5295 }
5296 }
5297
5298 #[test]
5299 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5300 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5301 assert_eq!(state.checks, Checks::Pending);
5302 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5303 }
5304
5305 #[test]
5306 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5307 let state = parse_pr(MERGED).expect("merged fixture parses");
5308 assert_eq!(state.state, PrLifecycle::Merged);
5309 assert_eq!(
5310 decide(&state, 0, 4, Duration::ZERO),
5311 Step::Done { merged: true }
5312 );
5313 }
5314
5315 #[test]
5316 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5317 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5318 assert_eq!(state.checks, Checks::Green);
5319 let authors: Vec<&str> = state
5320 .review_comments
5321 .iter()
5322 .map(|c| c.author.as_str())
5323 .collect();
5324 assert_eq!(
5325 authors,
5326 vec!["claude"],
5327 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5328 );
5329 match decide(&state, 0, 4, Duration::ZERO) {
5330 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5331 other => panic!("expected a fix round, got {other:?}"),
5332 }
5333 }
5334
5335 #[test]
5336 fn inline_review_comments_keep_their_file_and_line() {
5337 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5338 assert_eq!(comments.len(), 1);
5339 assert_eq!(comments[0].author, "claude[bot]");
5340 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5341 assert_eq!(comments[0].line, Some(231));
5342 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5343 }
5344
5345 #[test]
5346 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5347 assert!(
5348 is_noise(CODERABBIT_TRIGGER),
5349 "CodeRabbit's trigger notice declares itself not a review"
5350 );
5351 assert!(
5352 is_noise(CLAUDE_CHECKLIST),
5353 "a progress checklist asks for nothing"
5354 );
5355 assert!(
5356 !is_noise(CLAUDE_FINDING),
5357 "a review that names a bug is input, not noise"
5358 );
5359
5360 let mut clean = pr(Checks::Green, &[], 0);
5361 clean.review_comments.push(ReviewComment {
5362 author: "coderabbitai".to_owned(),
5363 path: None,
5364 line: None,
5365 body: CODERABBIT_TRIGGER.to_owned(),
5366 });
5367 clean.review_comments.retain(|c| !is_noise(&c.body));
5368 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5369
5370 let mut found = pr(Checks::Green, &[], 0);
5371 found.review_comments.push(ReviewComment {
5372 author: "claude".to_owned(),
5373 path: None,
5374 line: None,
5375 body: CLAUDE_FINDING.to_owned(),
5376 });
5377 found.review_comments.retain(|c| !is_noise(&c.body));
5378 assert!(matches!(
5379 decide(&found, 0, 4, Duration::ZERO),
5380 Step::Fix { .. }
5381 ));
5382 }
5383
5384 #[test]
5385 fn the_policy_table_holds_for_every_combination_that_matters() {
5386 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5387 (
5388 "pending checks are waited for, even on the last round",
5389 pr(Checks::Pending, &[], 0),
5390 4,
5391 4,
5392 Duration::ZERO,
5393 Step::Wait,
5394 ),
5395 (
5396 "red checks are fixed",
5397 pr(Checks::Red, &["editorconfig"], 0),
5398 0,
5399 4,
5400 Duration::ZERO,
5401 Step::Fix {
5402 reason: "1 check(s) failing: editorconfig".to_owned(),
5403 },
5404 ),
5405 (
5406 "green with comments is fixed, not merged",
5407 pr(Checks::Green, &[], 2),
5408 1,
5409 4,
5410 Duration::ZERO,
5411 Step::Fix {
5412 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5413 .to_owned(),
5414 },
5415 ),
5416 (
5417 "green and clean merges",
5418 pr(Checks::Green, &[], 0),
5419 3,
5420 4,
5421 Duration::ZERO,
5422 Step::Merge,
5423 ),
5424 (
5425 "an unreadable rollup is waited on while the grace lasts",
5426 pr(Checks::Unknown, &[], 0),
5427 0,
5428 4,
5429 Duration::ZERO,
5430 Step::Wait,
5431 ),
5432 (
5433 "an unreadable rollup is never merged once the grace is spent",
5434 pr(Checks::Unknown, &[], 0),
5435 0,
5436 4,
5437 CHECKS_GRACE,
5438 Step::GiveUp {
5439 reason: "no check status is readable on the pull request after 3 minute(s); \
5440 refusing to merge on a guess"
5441 .to_owned(),
5442 },
5443 ),
5444 ];
5445 for (what, state, round, budget, waited, want) in cases {
5446 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5447 }
5448 }
5449
5450 #[test]
5451 fn the_forge_verdict_survives_the_round_trip_from_gh() {
5452 let green = parse_pr(GREEN_OPEN).expect("parse");
5456 assert_eq!(green.blocking, Blocking::No);
5457 let red = parse_pr(RED_OPEN).expect("parse");
5458 assert_eq!(
5459 red.blocking,
5460 Blocking::No,
5461 "`UNSTABLE` is mergeable: the red check is one nobody requires"
5462 );
5463 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5464 let quiet =
5466 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5467 assert_eq!(quiet.blocking, Blocking::Unsaid);
5468 }
5469
5470 #[test]
5471 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5472 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5478 nonblocking.blocking = Blocking::No;
5479 assert_eq!(
5480 decide(&nonblocking, 0, 4, Duration::ZERO),
5481 Step::Merge,
5482 "the forge says nothing is in the way, so nothing is"
5483 );
5484
5485 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5487 blocking.blocking = Blocking::Yes;
5488 assert!(matches!(
5489 decide(&blocking, 0, 4, Duration::ZERO),
5490 Step::Fix { .. }
5491 ));
5492
5493 let mut commented = pr(Checks::Red, &["coverage"], 1);
5496 commented.blocking = Blocking::No;
5497 assert!(matches!(
5498 decide(&commented, 0, 4, Duration::ZERO),
5499 Step::Fix { .. }
5500 ));
5501
5502 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5504 unsaid.blocking = Blocking::Unsaid;
5505 assert!(matches!(
5506 decide(&unsaid, 0, 4, Duration::ZERO),
5507 Step::Fix { .. }
5508 ));
5509 }
5510
5511 #[test]
5512 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5513 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5514 red.blocking = Blocking::No;
5515 assert_eq!(
5516 decide(&red, 0, 4, Duration::ZERO),
5517 Step::Merge,
5518 "announcing must not change the decision"
5519 );
5520 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5521 assert!(said.contains("yukimemi/magi"), "{said}");
5522 assert!(said.contains("#16"), "{said}");
5523 assert!(
5524 said.contains("https://github.com/yukimemi/magi/pull/16"),
5525 "{said}"
5526 );
5527 assert!(
5528 said.contains("test (windows-latest)") && said.contains("coverage"),
5529 "{said}"
5530 );
5531
5532 let green = pr(Checks::Green, &["stale"], 0);
5534 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5535 }
5536
5537 #[test]
5538 fn the_repo_label_comes_from_the_pull_request_url() {
5539 let p = Path::new("/tmp/checkout");
5540 assert_eq!(
5541 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5542 "yukimemi/magi"
5543 );
5544 assert_eq!(repo_label(p, "not a url"), "checkout");
5545 }
5546
5547 #[test]
5548 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5549 let mut conflicted = pr(Checks::Green, &[], 0);
5554 conflicted.blocking = Blocking::Conflict;
5555 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5556
5557 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5561 red.blocking = Blocking::Conflict;
5562 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5563
5564 let mut merged = pr(Checks::Red, &[], 0);
5566 merged.blocking = Blocking::Conflict;
5567 merged.state = PrLifecycle::Merged;
5568 assert_eq!(
5569 decide(&merged, 0, 4, Duration::ZERO),
5570 Step::Done { merged: true }
5571 );
5572 }
5573
5574 #[test]
5575 fn the_forge_verdict_is_read_off_merge_state_status() {
5576 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5579 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5580 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5581 }
5582 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5583 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5584 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5585 for quiet in ["", "UNKNOWN"] {
5588 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5589 assert!(Blocking::of(quiet).stops_a_merge());
5590 }
5591 }
5592
5593 #[test]
5594 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5595 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5596 let jj = "could not determine current branch: failed to run git: not on any branch";
5598
5599 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5600 .expect("the forge says merged, so it merged");
5601 assert!(landed.ok);
5602 assert!(
5603 landed.detail.contains("but the pull request is merged"),
5604 "the record must not read as a clean success: {}",
5605 landed.detail
5606 );
5607 assert!(
5608 landed.detail.contains("not on any branch"),
5609 "and it must keep what the command actually said: {}",
5610 landed.detail
5611 );
5612
5613 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5615 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5616 assert!(merged_after_all(&argv, jj, None).is_none());
5618 }
5619
5620 #[test]
5621 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5622 let mut state = pr(Checks::Red, &["editorconfig"], 3);
5623 state.state = PrLifecycle::Closed;
5624 assert_eq!(
5625 decide(&state, 0, 4, Duration::ZERO),
5626 Step::Done { merged: false },
5627 "a human closing the pull request ends the loop, whatever CI says"
5628 );
5629 }
5630
5631 #[test]
5632 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5633 let red = decide(
5634 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5635 4,
5636 4,
5637 Duration::ZERO,
5638 );
5639 match red {
5640 Step::GiveUp { reason } => {
5641 assert!(reason.contains("editorconfig"), "reason: {reason}");
5642 assert!(reason.contains("test (macos)"), "reason: {reason}");
5643 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5644 }
5645 other => panic!("expected a give-up, got {other:?}"),
5646 }
5647
5648 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5649 match commented {
5650 Step::GiveUp { reason } => {
5651 assert!(reason.contains("unresolved"), "reason: {reason}");
5652 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5653 }
5654 other => panic!("expected a give-up, got {other:?}"),
5655 }
5656 }
5657
5658 #[test]
5659 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5660 let candidate_commit = "magi: candidate A (uncommitted work)";
5661 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5662 let argv = merge_argv(16, &subject);
5663
5664 assert!(argv.contains(&"--squash".to_owned()));
5665 assert!(argv.contains(&"--delete-branch".to_owned()));
5666 assert!(argv.contains(&"--subject".to_owned()));
5667 assert_eq!(
5668 argv.last().map(String::as_str),
5669 Some("add retries to the uploader"),
5670 "the subject must not be the candidate commit message"
5671 );
5672 assert_ne!(subject, candidate_commit);
5673 }
5674
5675 #[test]
5676 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5677 assert_eq!(
5678 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5679 "feat: a queue, an unattended loop, and a phone UI"
5680 );
5681 assert_eq!(
5682 merge_subject("", "# port the retry logic\n\ndetails"),
5683 "port the retry logic",
5684 "an empty title falls back to the task's first line, heading marks stripped"
5685 );
5686 }
5687
5688 #[test]
5689 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5690 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5691 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5692 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5693 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5694 assert_eq!(run_of(""), None);
5695 }
5696
5697 #[test]
5698 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5699 let mut out = Vec::new();
5700 push_if_outstanding(
5701 &mut out,
5702 ReviewComment {
5703 author: "yukimemi".to_owned(),
5704 path: None,
5705 line: None,
5706 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5707 },
5708 );
5709 assert!(out.is_empty());
5710 }
5711
5712 fn run_state() -> RunState {
5716 let mut state = RunState::new(
5717 std::path::PathBuf::from("/repo/magi"),
5718 "main".to_owned(),
5719 "abcdef1234".to_owned(),
5720 "add retries to the uploader".to_owned(),
5721 crate::config::Config::default(),
5722 );
5723 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5728 let nanos = std::time::SystemTime::now()
5729 .duration_since(std::time::UNIX_EPOCH)
5730 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5731 state.id = format!(
5732 "20261004-{nanos:06}-{:04x}",
5733 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5734 );
5735 state
5736 }
5737
5738 fn green_pr() -> PrState {
5739 PrState {
5740 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5741 number: 42,
5742 state: PrLifecycle::Open,
5743 checks: Checks::Green,
5744 blocking: Blocking::No,
5746 failing: Vec::new(),
5747 review_comments: vec![ReviewComment {
5748 author: "coderabbitai".to_owned(),
5749 path: Some("src/land.rs".to_owned()),
5750 line: Some(212),
5751 body: "this branch never checks the exit code".to_owned(),
5752 }],
5753 }
5754 }
5755
5756 #[test]
5757 fn github_facing_land_text_is_english_whatever_the_language() {
5758 let mut state = run_state();
5759 state.config.graph.language = "ja".to_owned();
5760 let comment = stop_comment(&state.id, "checks are still red");
5761 assert!(crate::github_text::check("", &comment).is_empty());
5762 assert!(comment.is_ascii(), "{comment}");
5763 assert!(comment.starts_with(MARKER));
5764
5765 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5766 let ja_at = p.find("Write all prose in ja").unwrap();
5767 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5768 assert!(ja_at < rule_at, "{p}");
5769 assert!(p.contains("stays in Japanese"), "{p}");
5770
5771 state.config.graph.language = "en".to_owned();
5772 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5773 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5774 assert!(!p.contains("does not apply"), "{p}");
5775 }
5776
5777 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5778
5779 fn panel() -> String {
5780 approval_panel(
5781 &run_state(),
5782 &green_pr(),
5783 NUMSTAT,
5784 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5785 &[
5786 "land: ask before merging".to_owned(),
5787 "land: colour the diff".to_owned(),
5788 ],
5789 "feat: merge approval from the phone",
5790 )
5791 }
5792
5793 #[test]
5794 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5795 let html = panel();
5796 for needle in [
5797 "42",
5798 "main",
5799 "src/land.rs",
5800 "src/web.rs",
5801 "assets/logo.png",
5802 "feat: merge approval from the phone",
5803 "land: ask before merging",
5804 "land: colour the diff",
5805 "coderabbitai",
5806 "this branch never checks the exit code",
5807 "green",
5808 ] {
5809 assert!(html.contains(needle), "the panel must state `{needle}`");
5810 }
5811 }
5812
5813 fn winning_candidate(summary: &str) -> Candidate {
5816 Candidate {
5817 index: 0,
5818 label: 'A',
5819 agent: "opus".to_owned(),
5820 branch: "magi/x/A".to_owned(),
5821 worktree: PathBuf::from("/wt/A"),
5822 summary: summary.to_owned(),
5823 stat: String::new(),
5824 files: 1,
5825 commits: 1,
5826 empty: false,
5827 failed: None,
5828 verified_noop: None,
5829 duration_ms: 0,
5830 folded: false,
5831 }
5832 }
5833
5834 fn uncontested_tally() -> Tally {
5835 Tally {
5836 first_choice: BTreeMap::from([('A', 1)]),
5837 borda: BTreeMap::new(),
5838 winner: 'A',
5839 rankings: 1,
5840 unanimous_initial: true,
5841 deliberated: false,
5842 changed_votes: 0,
5843 unanimous_final: true,
5844 tie_break: None,
5845 judges: 1,
5846 present: 1,
5847 quorum: 1,
5848 met_quorum: true,
5849 uncontested: None,
5850 }
5851 }
5852
5853 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5854 ReviewRecord {
5855 attempts: 0,
5856 reviewer,
5857 agent: agent.to_owned(),
5858 summary: summary.to_owned(),
5859 findings: Vec::new(),
5860 vote: None,
5861 failed: None,
5862 duration_ms: 0,
5863 }
5864 }
5865
5866 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5867 let answered = reviews.len();
5868 ReviewRound {
5869 round,
5870 head: "abc1234".to_owned(),
5871 verified_head: None,
5872 verified_at: None,
5873 reviews,
5874 e2e: Vec::new(),
5875 verify_retried: false,
5876 e2e_deferred: false,
5877 e2e_defer_reason: None,
5878 fix: None,
5879 blocking: 0,
5880 answered,
5881 expected: answered,
5882 clean: true,
5883 progressed: false,
5884 vote_split: false,
5885 reconsideration: Vec::new(),
5886 verdict: None,
5887 }
5888 }
5889
5890 #[test]
5891 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5892 let en = panel();
5893 assert!(en.contains("Task"), "{en}");
5894 assert!(en.contains("add retries to the uploader"), "{en}");
5895
5896 let mut state = run_state();
5897 state.config.graph.language = "ja".to_owned();
5898 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5899 assert!(ja.contains("タスク"), "{ja}");
5900 assert!(
5901 ja.contains("add retries to the uploader"),
5902 "the task itself is not translated: {ja}"
5903 );
5904 }
5905
5906 #[test]
5907 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5908 let html = panel();
5912 assert!(!html.contains("What changed"), "{html}");
5913 assert!(!html.contains("Review verdict"), "{html}");
5914 }
5915
5916 #[test]
5917 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5918 let mut state = run_state();
5919 state.candidates = vec![winning_candidate("")];
5920 state.tally = Some(uncontested_tally());
5921 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5922 assert!(
5923 !html.contains("What changed"),
5924 "an empty summary must not render an empty box: {html}"
5925 );
5926 }
5927
5928 #[test]
5929 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5930 let mut state = run_state();
5931 state.candidates = vec![winning_candidate(
5932 "Added a retry loop around the uploader PUT call.",
5933 )];
5934 state.tally = Some(uncontested_tally());
5935 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5936 assert!(en.contains("What changed"), "{en}");
5937 assert!(
5938 en.contains("Added a retry loop around the uploader PUT call."),
5939 "{en}"
5940 );
5941
5942 state.config.graph.language = "ja".to_owned();
5943 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5944 assert!(ja.contains("変更内容"), "{ja}");
5945 assert!(
5946 ja.contains("Added a retry loop around the uploader PUT call."),
5947 "{ja}"
5948 );
5949 }
5950
5951 #[test]
5952 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5953 let mut state = run_state();
5954 state.reviews = vec![
5955 review_round(
5956 1,
5957 vec![review_record(1, "alpha", "found a race, sent back")],
5958 ),
5959 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5960 ];
5961 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5962 assert!(en.contains("Review verdict"), "{en}");
5963 assert!(en.contains("race is fixed, clean"), "{en}");
5964 assert!(
5965 !en.contains("found a race, sent back"),
5966 "only the round that actually cleared the merge should show: {en}"
5967 );
5968
5969 state.config.graph.language = "ja".to_owned();
5970 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5971 assert!(ja.contains("レビューの結論"), "{ja}");
5972 assert!(ja.contains("レビュアー"), "{ja}");
5973 assert!(ja.contains("race is fixed, clean"), "{ja}");
5974 }
5975
5976 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5982 ReviewRecord {
5983 attempts: 0,
5984 reviewer,
5985 agent: agent.to_owned(),
5986 summary: String::new(),
5987 findings: Vec::new(),
5988 vote: None,
5989 failed: Some(reason.to_owned()),
5990 duration_ms: 0,
5991 }
5992 }
5993
5994 #[test]
5995 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5996 let mut state = run_state();
5997 state.reviews = vec![review_round(
5998 1,
5999 vec![
6000 review_record(1, "alpha", "clean, nothing to add"),
6001 unanswered_review_record(2, "beta", "timed out"),
6002 ],
6003 )];
6004 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6005 assert!(en.contains("clean, nothing to add"), "{en}");
6006 assert!(
6007 en.contains("produced no answer: timed out"),
6008 "a seat that never answered must say so, not render a blank box: {en}"
6009 );
6010 assert!(
6011 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6012 "no reviewer box may be left empty: {en}"
6013 );
6014
6015 state.config.graph.language = "ja".to_owned();
6016 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6017 assert!(ja.contains("回答なし: timed out"), "{ja}");
6018 }
6019
6020 #[test]
6021 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6022 let html = panel();
6023 assert!(!html.contains("<script"), "no script survives the csp");
6024 assert!(!html.contains("<form"), "form-action is 'none'");
6025 let pr = green_pr();
6026 assert_eq!(
6027 html.matches("http").count(),
6028 html.matches(pr.url.as_str()).count(),
6029 "the only http url in the panel is the pull request's own link"
6030 );
6031 assert!(
6032 html.contains("target=\"_blank\""),
6033 "the PR link must open in a new tab so it escapes the iframe"
6034 );
6035 assert!(
6036 html.contains("rel=\"noopener noreferrer\""),
6037 "external links opening in new tabs must carry noopener noreferrer"
6038 );
6039 }
6040
6041 #[test]
6042 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6043 let html = panel();
6044 assert!(
6045 html.contains(">+</span>"),
6046 "an added line carries a `+` in the gutter, not only a background"
6047 );
6048 assert!(
6049 html.contains(">-</span>"),
6050 "a removed line carries a `-` in the gutter, not only a background"
6051 );
6052 assert!(
6053 html.contains(">new line</span>"),
6054 "the marker is moved to the gutter, so the body is printed once without it"
6055 );
6056 }
6057
6058 #[test]
6059 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6060 let total = DIFF_MAX_LINES + 100;
6061 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6062 let html = approval_panel(
6063 &run_state(),
6064 &green_pr(),
6065 NUMSTAT,
6066 &diff,
6067 &[],
6068 "feat: something long",
6069 );
6070 assert!(
6071 html.contains(&format!("100 of {total} diff lines omitted")),
6072 "the note must say exactly how much was cut"
6073 );
6074 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6075 assert!(
6076 !html.contains(&format!("line {DIFF_MAX_LINES}")),
6077 "nothing past the threshold is rendered"
6078 );
6079 assert!(
6080 html.contains("/repo/magi"),
6081 "the note says where the rest is"
6082 );
6083 }
6084
6085 #[test]
6086 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6087 let html = approval_panel(
6088 &run_state(),
6089 &green_pr(),
6090 "1\t2\tsrc/<b>&\"x\"'.rs",
6091 "",
6092 &[],
6093 "subject",
6094 );
6095 assert!(html.contains("src/<b>&"x"'.rs"));
6096 assert!(
6097 !html.contains("<b>"),
6098 "an agent-influenced path must never become markup"
6099 );
6100 }
6101
6102 #[tokio::test]
6103 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6104 let a = std::path::PathBuf::from("/repo/a");
6105 let b = std::path::PathBuf::from("/repo/b");
6106
6107 let held = repo_merge_lock(&a).lock_owned().await;
6108
6109 assert!(
6112 repo_merge_lock(&a).try_lock().is_err(),
6113 "a second merge into the same repository must not proceed concurrently"
6114 );
6115
6116 assert!(
6120 repo_merge_lock(&b).try_lock().is_ok(),
6121 "a different repository's merge lock must be independent"
6122 );
6123
6124 drop(held);
6125 assert!(
6126 repo_merge_lock(&a).try_lock().is_ok(),
6127 "the lock is released once the holder is done"
6128 );
6129 }
6130
6131 fn repair_state(major: bool, reject: bool) -> RunState {
6132 let mut state = landing_state();
6133 let mut rec = review_record(1, "alpha", "precise rejection reason");
6134 rec.vote = Some(if reject {
6135 crate::verdict::ReviewVote::Reject
6136 } else {
6137 crate::verdict::ReviewVote::Approve
6138 });
6139 if major {
6140 rec.findings = contested().findings;
6141 }
6142 let mut round = review_round(1, vec![rec]);
6143 round.clean = false;
6144 round.blocking = usize::from(major);
6145 state.reviews.push(round);
6146 state.config.graph.review_rounds = 1;
6147 state
6148 }
6149
6150 #[test]
6151 fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6152 for (major, reject) in [(true, false), (false, true), (true, true)] {
6153 let state = repair_state(major, reject);
6154 assert!(repair_available(&state));
6155 assert_eq!(approval_choices(&state), [APPROVE, HOLD, REPAIR]);
6156 }
6157 assert!(!repair_available(&repair_state(false, false)));
6158 let mut clean = repair_state(true, false);
6159 clean.reviews[0].clean = true;
6160 assert_eq!(approval_choices(&clean), [APPROVE, HOLD]);
6161 assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6162 }
6163
6164 #[tokio::test]
6165 async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6166 let mut state = repair_state(true, false);
6167 let pr = green_pr();
6168 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6169 .await
6170 .unwrap();
6171 let store = ask::Questions::open();
6172 let id = state.land_approval.as_ref().unwrap().question.clone();
6173 store
6175 .update(&id, |q| {
6176 q.choices = vec![APPROVE.into(), HOLD.into()];
6177 Ok(())
6178 })
6179 .unwrap();
6180 let upgraded = store.get(&id).unwrap();
6181 assert_eq!(upgraded.choices, [APPROVE, HOLD, REPAIR]);
6182 assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6183 assert!(
6184 store
6185 .list()
6186 .iter()
6187 .any(|q| q.id == id && q.choices == upgraded.choices)
6188 );
6189 assert!(
6190 crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6191 );
6192 assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6193 store
6194 .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6195 .unwrap();
6196 let binding = state.land_approval.clone();
6197 assert_eq!(
6198 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6199 .await
6200 .unwrap(),
6201 ApprovalGate::Repair
6202 );
6203 assert!(state.land_approval.is_none());
6204 assert_eq!(state.merge_repairs.len(), 1);
6205 let request = &state.merge_repairs[0];
6206 assert_eq!(request.question, id);
6207 assert_eq!(request.head, "abc");
6208 assert_eq!(request.pr, pr.url);
6209 assert_eq!(
6210 request.reviews[0].reviews[0].summary,
6211 "precise rejection reason"
6212 );
6213 assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6214 assert_eq!(
6215 request.reviews[0].reviews[0].findings[0].detail,
6216 state.reviews[0].reviews[0].findings[0].detail
6217 );
6218 state.land_approval = binding;
6220 assert_eq!(
6221 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6222 .await
6223 .unwrap(),
6224 ApprovalGate::Repair
6225 );
6226 assert_eq!(state.merge_repairs.len(), 1);
6227 }
6228
6229 #[tokio::test]
6230 async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6231 for choice in [REPAIR, HOLD] {
6232 let mut state = repair_state(false, true);
6233 let observed = seen("abc", Checks::Green, "CLEAN", false);
6234 approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6235 .await
6236 .unwrap();
6237 let id = state.land_approval.as_ref().unwrap().question.clone();
6238 ask::Questions::open()
6239 .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6240 .unwrap();
6241 let forge = Scripted::new(
6242 vec![observed],
6243 if choice == REPAIR {
6244 vec![(true, "")]
6245 } else {
6246 vec![]
6247 },
6248 );
6249 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6250 .await
6251 .unwrap();
6252 assert!(
6253 forge
6254 .argvs()
6255 .iter()
6256 .all(|a| a.iter().any(|s| s == "--disable-auto"))
6257 );
6258 assert!(!forge.calls().contains(&"fix"));
6259 assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6260 assert_ne!(state.status, RunStatus::Merged);
6261 }
6262 }
6263
6264 #[test]
6265 fn only_the_merge_choice_merges_and_silence_holds() {
6266 let table = [
6267 (None, Approval::Hold),
6268 (Some("merge"), Approval::Merge),
6269 (Some(" merge\n"), Approval::Merge),
6270 (Some("hold"), Approval::Hold),
6271 (Some(""), Approval::Hold),
6272 (Some("yes"), Approval::Hold),
6273 ];
6274 for (answer, want) in table {
6275 assert_eq!(
6276 approval(answer),
6277 want,
6278 "answer {answer:?} must resolve to {want:?}"
6279 );
6280 }
6281 }
6282
6283 #[tokio::test]
6284 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6285 let mut state = landing_state();
6286 state.config.graph.land_approval = true;
6287 let pr = green_pr();
6288
6289 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6290 .await
6291 .unwrap();
6292 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6293 assert!(
6294 !state.parked,
6295 "approval_gate itself never sets `parked`; only its caller does"
6296 );
6297
6298 let store = ask::Questions::open();
6299 let filed: Vec<_> = store
6300 .list()
6301 .into_iter()
6302 .filter(|q| q.run == state.id)
6303 .collect();
6304 assert_eq!(filed.len(), 1, "exactly one question is filed");
6305 assert_eq!(filed[0].node, APPROVAL_NODE);
6306 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
6307 assert!(filed[0].status.open());
6308
6309 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6313 .await
6314 .unwrap();
6315 assert_eq!(again, ApprovalGate::Pending);
6316 let still_one = store
6317 .list()
6318 .into_iter()
6319 .filter(|q| q.run == state.id)
6320 .count();
6321 assert_eq!(
6322 still_one, 1,
6323 "asking twice must not double-file the question"
6324 );
6325 }
6326
6327 #[tokio::test]
6328 async fn approving_the_existing_question_is_read_back_as_approved() {
6329 crate::run::pin_test_home();
6330 let mut state = run_state();
6331 state.config.graph.land_approval = true;
6332 let pr = green_pr();
6333 assert_eq!(
6334 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6335 .await
6336 .unwrap(),
6337 ApprovalGate::Pending
6338 );
6339
6340 let store = ask::Questions::open();
6341 let mut q = store
6342 .list()
6343 .into_iter()
6344 .find(|q| q.run == state.id)
6345 .expect("filed above");
6346 let talks = crate::talk::Talks::open();
6347 let cfg = crate::config::Config {
6348 agents: vec![crate::config::AgentSpec {
6349 id: "mock".into(),
6350 kind: crate::config::AgentKind::Command,
6351 model: None,
6352 command: vec!["true".into()],
6353 extra_args: Vec::new(),
6354 env: Default::default(),
6355 prompt_delivery: None,
6356 }],
6357 ..crate::config::Config::default()
6358 };
6359 let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6360 crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6361 crate::talk::drain(&mut talk, &talks).unwrap();
6362 assert_eq!(
6363 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6364 .await
6365 .unwrap(),
6366 ApprovalGate::Pending,
6367 "consultation cannot approve or hold the merge"
6368 );
6369 q = store.get(&q.id).unwrap();
6370 assert!(q.answer.is_none());
6371 talk.turns.push(crate::talk::Turn {
6372 breaks: Some(Vec::new()),
6373 messages: Vec::new(),
6374 posted_by: None,
6375 who: crate::talk::Who::Operator,
6376 body: "Merge this pull request now".into(),
6377 at: Timestamp::now(),
6378 attachments: Vec::new(),
6379 usage: None,
6380 });
6381 talks.put(&mut talk).unwrap();
6382 crate::consult::validate_answer(
6383 &q,
6384 &talks,
6385 &talk.id,
6386 crate::queue::CHAT_NODE,
6387 APPROVE,
6388 Some("Merge this pull request now"),
6389 )
6390 .unwrap();
6391 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6392 store.put(&mut q).unwrap();
6393
6394 assert_eq!(
6395 approval_gate(&mut state, &pr, "feat: x", None, "abc")
6396 .await
6397 .unwrap(),
6398 ApprovalGate::Approved
6399 );
6400 }
6401
6402 #[tokio::test]
6403 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6404 crate::run::pin_test_home();
6405 let store = ask::Questions::open();
6406
6407 let mut held_state = run_state();
6408 held_state.config.graph.land_approval = true;
6409 let pr = green_pr();
6410 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6411 .await
6412 .unwrap();
6413 let mut q = store
6414 .list()
6415 .into_iter()
6416 .find(|q| q.run == held_state.id)
6417 .expect("filed above");
6418 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6419 store.put(&mut q).unwrap();
6420 assert_eq!(
6421 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6422 .await
6423 .unwrap(),
6424 ApprovalGate::Held
6425 );
6426
6427 let mut abandoned_state = run_state();
6428 abandoned_state.config.graph.land_approval = true;
6429 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6430 .await
6431 .unwrap();
6432 let mut q = store
6433 .list()
6434 .into_iter()
6435 .find(|q| q.run == abandoned_state.id)
6436 .expect("filed above");
6437 q.abandon("no answer within the timeout");
6438 store.put(&mut q).unwrap();
6439 assert_eq!(
6440 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6441 .await
6442 .unwrap(),
6443 ApprovalGate::Held,
6444 "silence must never merge"
6445 );
6446 }
6447
6448 fn contested() -> ContestedHandoff {
6449 let finding = |id: &str, n: u32| crate::verdict::Finding {
6450 id: id.to_owned(),
6451 severity: crate::verdict::Severity::Major,
6452 file: Some("src/a.rs".to_owned()),
6453 line: Some(n),
6454 title: format!("problem {id}"),
6455 detail: String::new(),
6456 };
6457 ContestedHandoff {
6458 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6459 rejecters: vec![(1, "alpha".to_owned())],
6460 }
6461 }
6462
6463 #[test]
6464 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6465 let mut state = run_state();
6466 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6467 state.contested_handoff = Some(contested());
6468 assert!(contested_to_ask(&state).is_some());
6469 state.config.graph.hold_contested_merge = false;
6470 assert!(
6471 contested_to_ask(&state).is_none(),
6472 "the switch restores today"
6473 );
6474 }
6475
6476 #[test]
6477 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6478 let q = ask::Question::new(
6479 "run-1".to_owned(),
6480 APPROVAL_NODE.to_owned(),
6481 "land".to_owned(),
6482 "Merge?".to_owned(),
6483 String::new(),
6484 vec![APPROVE.to_owned(), HOLD.to_owned()],
6485 );
6486 let none = deputy_brief(&q, None);
6487 assert!(none.contains("could not be read"), "{none}");
6488 assert!(none.contains("Silence is a hold"), "{none}");
6489
6490 let mut state = run_state();
6491 state.pr = Some(crate::run::PrRecord {
6492 url: "https://example.test/pull/7".to_owned(),
6493 number: 7,
6494 state: "open".to_owned(),
6495 checks: "green".to_owned(),
6496 round: 0,
6497 rounds: 3,
6498 red_at_merge: Vec::new(),
6499 });
6500 state.contested_handoff = Some(contested());
6501 let b = deputy_brief(&q, Some(&state));
6502 assert!(b.contains("https://example.test/pull/7"), "{b}");
6503 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6504 assert!(b.contains("#1"), "the rejecting seat: {b}");
6505 state.contested_handoff = None;
6506 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6507 }
6508
6509 #[test]
6510 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6511 for lang in ["en", "ja"] {
6512 let mut cfg = crate::config::Config::default();
6513 cfg.graph.language = lang.to_owned();
6514 let w = words(&cfg.graph.language);
6515 let text = w.approval_detail(
6516 "https://github.com/yukimemi/magi/pull/42",
6517 "main",
6518 "feat: x",
6519 Some(&contested()),
6520 );
6521 assert!(text.contains("pull/42"), "{text}");
6522 assert!(
6523 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6524 "{text}"
6525 );
6526 assert!(text.contains("R3-1-5"), "{text}");
6527 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6528 assert!(text.contains("2"), "the rest are counted: {text}");
6529 assert!(text.contains("#1 (alpha)"), "{text}");
6530 }
6531 let plain = words("en").approval_detail("u", "main", "s", None);
6532 assert!(!plain.contains("reject"), "{plain}");
6533 }
6534
6535 #[tokio::test]
6536 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6537 crate::run::pin_test_home();
6538 let mut state = run_state();
6539 state.config.graph.land_approval = false;
6540 state.contested_handoff = Some(contested());
6541 let pr = green_pr();
6542 let c = contested_to_ask(&state);
6543 assert_eq!(
6544 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6545 .await
6546 .unwrap(),
6547 ApprovalGate::Pending,
6548 "silence is a hold"
6549 );
6550 let store = ask::Questions::open();
6551 let filed: Vec<_> = store
6552 .list()
6553 .into_iter()
6554 .filter(|q| q.run == state.id)
6555 .collect();
6556 assert_eq!(filed.len(), 1);
6557 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6558
6559 assert_eq!(
6560 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6561 .await
6562 .unwrap(),
6563 ApprovalGate::Pending
6564 );
6565 let mut q = store
6566 .list()
6567 .into_iter()
6568 .find(|q| q.run == state.id)
6569 .unwrap();
6570 assert_eq!(q.id, filed[0].id, "the same question after a resume");
6571 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6572 store.put(&mut q).unwrap();
6573 assert_eq!(
6574 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6575 .await
6576 .unwrap(),
6577 ApprovalGate::Approved
6578 );
6579 }
6580
6581 #[test]
6582 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6583 let rows = parse_numstat(NUMSTAT);
6584 assert_eq!(
6585 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6586 ["src/web.rs", "src/land.rs", "assets/logo.png"]
6587 );
6588 assert_eq!(rows[2].added, None, "a binary file has no line counts");
6589 }
6590 #[test]
6591 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6592 let mut state = run_state();
6596 state.config.graph.language = "ja".to_owned();
6597 let pr = green_pr();
6598 let commits = ["c1".to_owned()];
6599
6600 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6601 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6602 assert!(ja.contains("squash されるコミット"), "{ja}");
6603 assert!(ja.contains("レビューコメント"), "{ja}");
6604 assert!(ja.contains("差分"), "{ja}");
6605 assert!(
6606 !ja.contains("Commits being squashed"),
6607 "no English left over"
6608 );
6609
6610 let w = words("ja");
6611 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6612 assert!(
6613 w.approval_detail("http://x/1", "main", "feat: x", None)
6614 .contains("パネル")
6615 );
6616
6617 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6619 assert!(ja.contains("feat: x"), "nor is the merge subject");
6620
6621 state.config.graph.language = "en".to_owned();
6624 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6625 assert!(en.contains("Commits being squashed"), "{en}");
6626 assert_eq!(words("Klingon").html_lang, "en");
6627 }
6628
6629 #[test]
6633 fn pick_open_pr_classifies_by_count_and_base() {
6634 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6635 assert_eq!(
6636 pick_open_pr(one, "main").unwrap(),
6637 OpenPr::One {
6638 url: "https://x/pull/58".into(),
6639 title: "t".into()
6640 }
6641 );
6642 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6643 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6644 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6645 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6646 assert_eq!(
6647 pick_open_pr(two, "main").unwrap(),
6648 OpenPr::Many(vec!["u1".into(), "u2".into()])
6649 );
6650 let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6652 assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6653 assert!(pick_open_pr("not json", "main").is_err());
6654 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6656 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6657 }
6658
6659 #[test]
6660 fn pick_merged_pr_picks_the_unique_match() {
6661 let json = r#"[
6662 {"url": "https://github.com/o/r/pull/42", "number": 42,
6663 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6664 ]"#;
6665 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6666 let found = pick_merged_pr(json, "main", created_at)
6667 .expect("valid json")
6668 .expect("one unambiguous match");
6669 assert_eq!(found.url, "https://github.com/o/r/pull/42");
6670 assert_eq!(found.number, 42);
6671 }
6672
6673 #[test]
6677 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6678 let json = r#"[
6679 {"url": "https://github.com/o/r/pull/42", "number": 42,
6680 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6681 {"url": "https://github.com/o/r/pull/43", "number": 43,
6682 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6683 ]"#;
6684 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6685 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6686 }
6687
6688 #[test]
6692 fn pick_merged_pr_ignores_a_different_base_branch() {
6693 let json = r#"[
6694 {"url": "https://github.com/o/r/pull/42", "number": 42,
6695 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6696 ]"#;
6697 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6698 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6699 }
6700
6701 #[test]
6704 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6705 let json = r#"[
6706 {"url": "https://github.com/o/r/pull/42", "number": 42,
6707 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6708 ]"#;
6709 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6710 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6711 }
6712
6713 #[test]
6714 fn slug_of_pr_url_reads_host_owner_and_repo() {
6715 assert_eq!(
6716 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6717 Some("github.com/yukimemi/shun")
6718 );
6719 }
6720
6721 #[test]
6722 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6723 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6724 assert_eq!(slug_of_pr_url("not a url at all"), None);
6725 assert_eq!(slug_of_pr_url("https://github.com"), None);
6726 }
6727
6728 #[test]
6729 fn slug_of_repo_url_reads_host_owner_and_repo() {
6730 assert_eq!(
6731 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6732 Some("github.com/yukimemi/magi")
6733 );
6734 assert_eq!(slug_of_repo_url("https://github.com"), None);
6735 }
6736
6737 #[test]
6738 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6739 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6740 .expect("same repo, different case");
6741 }
6742
6743 #[test]
6748 fn ensure_same_repo_refuses_a_different_repo() {
6749 let err =
6750 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6751 let msg = format!("{err:#}");
6752 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6753 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6754 }
6755
6756 #[test]
6761 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6762 let err = ensure_same_repo(
6763 "github.com/yukimemi/magi",
6764 "github.example.com/yukimemi/magi",
6765 )
6766 .unwrap_err();
6767 let msg = format!("{err:#}");
6768 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6769 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6770 }
6771
6772 #[tokio::test]
6777 async fn find_external_merge_returns_none_without_a_winner() {
6778 let state = RunState::new(
6779 PathBuf::from("/no/such/repo"),
6780 "main".to_owned(),
6781 "0000000000000000000000000000000000000000".to_owned(),
6782 "irrelevant".to_owned(),
6783 crate::config::Config::default(),
6784 );
6785 assert_eq!(find_external_merge(&state).await.unwrap(), None);
6786 }
6787
6788 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6789 let mut run = RunState::new(
6790 PathBuf::from(repo),
6791 "main".to_owned(),
6792 "abcdef1234".to_owned(),
6793 "x".to_owned(),
6794 crate::config::Config::default(),
6795 );
6796 run.id = id.to_owned();
6797 run.status = status;
6798 run.pr = Some(crate::run::PrRecord {
6799 number: url.rsplit('/').next().unwrap().parse().unwrap(),
6800 url: url.to_owned(),
6801 state: state.to_owned(),
6802 checks: "red".to_owned(),
6803 round: 0,
6804 rounds: 2,
6805 red_at_merge: Vec::new(),
6806 });
6807 run.save_under(home).unwrap();
6808 }
6809
6810 fn recorded(home: &Path, id: &str) -> String {
6811 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6812 serde_json::from_str::<RunState>(&body)
6813 .unwrap()
6814 .pr
6815 .unwrap()
6816 .state
6817 }
6818
6819 const PR: &str = "https://github.com/o/r/pull/7";
6820
6821 #[test]
6822 fn write_through_updates_predecessors_and_siblings_only() {
6823 let tmp = tempfile::tempdir().unwrap();
6824 let h = tmp.path();
6825 pr_run(
6826 h,
6827 "20261004-100000-aaaa",
6828 "/repo/r",
6829 RunStatus::Superseded,
6830 PR,
6831 "open",
6832 );
6833 pr_run(
6834 h,
6835 "20261004-100100-bbbb",
6836 "/repo/r",
6837 RunStatus::Blocked,
6838 PR,
6839 "open",
6840 );
6841 pr_run(
6843 h,
6844 "20261004-100200-cccc",
6845 "/repo/r",
6846 RunStatus::Landing,
6847 PR,
6848 "open",
6849 );
6850 pr_run(
6852 h,
6853 "20261004-100300-dddd",
6854 "/repo/other",
6855 RunStatus::Blocked,
6856 "https://github.com/o/other/pull/7",
6857 "open",
6858 );
6859 pr_run(
6861 h,
6862 "20261004-100400-eeee",
6863 "/repo/r",
6864 RunStatus::Blocked,
6865 "https://github.com/o/r/pull/8",
6866 "open",
6867 );
6868 pr_run(
6869 h,
6870 "20261004-100500-ffff",
6871 "/repo/r",
6872 RunStatus::Merged,
6873 PR,
6874 "open",
6875 );
6876 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6877
6878 assert_eq!(
6879 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6880 2
6881 );
6882 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6883 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6884 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6885 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6886 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6887 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6889 assert_eq!(
6891 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6892 0
6893 );
6894 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6895 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6896 }
6897
6898 #[test]
6899 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6900 let tmp = tempfile::tempdir().unwrap();
6901 let h = tmp.path();
6902 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6903 pr_run(
6904 h,
6905 "20261004-100000-aaaa",
6906 "/repo/r",
6907 RunStatus::Superseded,
6908 &url(1),
6909 "open",
6910 );
6911 pr_run(
6912 h,
6913 "20261004-100100-bbbb",
6914 "/repo/r",
6915 RunStatus::Blocked,
6916 &url(2),
6917 "open",
6918 );
6919 pr_run(
6920 h,
6921 "20261004-100200-cccc",
6922 "/repo/r",
6923 RunStatus::Ready,
6924 &url(3),
6925 "open",
6926 );
6927 pr_run(
6928 h,
6929 "20261004-100300-dddd",
6930 "/repo/r",
6931 RunStatus::Ready,
6932 &url(4),
6933 "open",
6934 );
6935 pr_run(
6936 h,
6937 "20261004-100400-eeee",
6938 "/repo/r",
6939 RunStatus::Implementing,
6940 &url(1),
6941 "open",
6942 );
6943 assert_eq!(stale_open_prs(h).len(), 4);
6944
6945 let mut known = BTreeMap::new();
6946 known.insert(url(1), PrLifecycle::Merged);
6947 known.insert(url(2), PrLifecycle::Closed);
6948 known.insert(url(3), PrLifecycle::Open);
6949 assert_eq!(apply_pr_states(h, &known), 2);
6951 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6952 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6953 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6954 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6955 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6956 assert_eq!(apply_pr_states(h, &known), 0);
6957 }
6958
6959 use std::collections::VecDeque;
6962 use std::sync::Mutex;
6963
6964 struct Scripted {
6967 views: Mutex<VecDeque<Seen>>,
6968 merges: Mutex<VecDeque<(bool, String)>>,
6969 fix: Mutex<Option<Fixed>>,
6970 log: Mutex<Vec<&'static str>>,
6971 argvs: Mutex<Vec<Vec<String>>>,
6972 required: Mutex<Option<BTreeSet<String>>>,
6973 merged: Mutex<bool>,
6976 queued: Mutex<bool>,
6977 unreadable_after_merge: Mutex<bool>,
6979 reruns: Mutex<Vec<String>>,
6981 rerun_fails: Mutex<bool>,
6982 clock: Mutex<i64>,
6984 }
6985
6986 impl Scripted {
6987 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6988 Self {
6989 views: Mutex::new(views.into()),
6990 merges: Mutex::new(
6991 merges
6992 .into_iter()
6993 .map(|(ok, m)| (ok, m.to_owned()))
6994 .collect(),
6995 ),
6996 fix: Mutex::new(None),
6997 log: Mutex::new(Vec::new()),
6998 argvs: Mutex::new(Vec::new()),
6999 required: Mutex::new(None),
7000 merged: Mutex::new(false),
7001 queued: Mutex::new(false),
7002 unreadable_after_merge: Mutex::new(false),
7003 reruns: Mutex::new(Vec::new()),
7004 rerun_fails: Mutex::new(false),
7005 clock: Mutex::new(1_000),
7006 }
7007 }
7008 fn argvs(&self) -> Vec<Vec<String>> {
7009 self.argvs.lock().unwrap().clone()
7010 }
7011 fn calls(&self) -> Vec<&'static str> {
7012 self.log.lock().unwrap().clone()
7013 }
7014 }
7015
7016 impl Forge for Scripted {
7017 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7018 self.log.lock().unwrap().push("view");
7019 if *self.unreadable_after_merge.lock().unwrap()
7020 && !self.argvs.lock().unwrap().is_empty()
7021 {
7022 anyhow::bail!("forge unreachable");
7023 }
7024 let mut v = self.views.lock().unwrap();
7025 let mut seen = if v.len() > 1 {
7026 v.pop_front().unwrap()
7027 } else {
7028 v[0].clone()
7029 };
7030 if *self.merged.lock().unwrap() {
7031 seen.pr.state = PrLifecycle::Merged;
7032 }
7033 Ok(seen)
7034 }
7035 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7036 self.log.lock().unwrap().push("merge");
7037 self.argvs.lock().unwrap().push(argv.to_vec());
7038 let out = self
7039 .merges
7040 .lock()
7041 .unwrap()
7042 .pop_front()
7043 .expect("unscripted merge");
7044 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7045 {
7046 *self.merged.lock().unwrap() = true;
7047 }
7048 Ok(out)
7049 }
7050 async fn poll(&self) {
7051 self.log.lock().unwrap().push("poll");
7052 *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7053 }
7054 async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7055 self.log.lock().unwrap().push("rerun");
7056 self.reruns.lock().unwrap().push(run.to_owned());
7057 if *self.rerun_fails.lock().unwrap() {
7058 anyhow::bail!("gh run rerun: no");
7059 }
7060 Ok(())
7061 }
7062 fn now(&self) -> i64 {
7063 *self.clock.lock().unwrap()
7064 }
7065 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7066 self.required.lock().unwrap().clone()
7067 }
7068 async fn fix(
7069 &self,
7070 _state: &mut RunState,
7071 _pr: &PrState,
7072 _round: usize,
7073 _budget: usize,
7074 _reason: &str,
7075 _logs: &str,
7076 ) -> Result<Fixed> {
7077 self.log.lock().unwrap().push("fix");
7078 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7079 }
7080 }
7081
7082 const REFUSED: &str =
7083 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7084
7085 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7086 let mut pr = green_pr();
7087 pr.checks = checks;
7088 pr.blocking = Blocking::of(merge_state);
7089 if !comments {
7090 pr.review_comments.clear();
7091 }
7092 Seen {
7093 pr,
7094 title: "feat: x".to_owned(),
7095 failing_urls: Vec::new(),
7096 head: head.to_owned(),
7097 rollup_head: head.to_owned(),
7098 merge_state: merge_state.to_owned(),
7099 contexts: Vec::new(),
7100 base: "main".to_owned(),
7101 }
7102 }
7103
7104 fn landing_state() -> RunState {
7105 crate::run::pin_test_home();
7106 let mut state = run_state();
7107 state.config.graph.land_approval = false;
7108 state
7109 }
7110
7111 #[test]
7112 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7113 assert!(!awaiting_new_head(None, "aaa"));
7114 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7115 assert!(awaiting_new_head(Some("abc123"), "def456"));
7116 assert!(awaiting_new_head(Some("abc123"), ""));
7117 }
7118
7119 #[test]
7120 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7121 let open = |c, m: &str| seen("a", c, m, false);
7122 let table = [
7123 (None, false, Refused::Pending),
7124 (
7125 Some(open(Checks::Pending, "BLOCKED")),
7126 false,
7127 Refused::Pending,
7128 ),
7129 (
7130 Some(open(Checks::Unknown, "BLOCKED")),
7131 false,
7132 Refused::Pending,
7133 ),
7134 (
7135 Some(open(Checks::Green, "UNKNOWN")),
7136 false,
7137 Refused::Pending,
7138 ),
7139 (Some(open(Checks::Green, "")), false, Refused::Pending),
7140 (
7141 Some(open(Checks::Green, "BLOCKED")),
7142 false,
7143 Refused::Recheck,
7144 ),
7145 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7146 ];
7147 for (after, rechecked, want) in table {
7148 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7149 }
7150 let mut closed = open(Checks::Green, "CLEAN");
7151 closed.pr.state = PrLifecycle::Closed;
7152 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7153 }
7154
7155 #[tokio::test]
7156 async fn a_normal_landing_merges_on_the_first_look() {
7157 let mut state = landing_state();
7158 let forge = Scripted::new(
7159 vec![seen("a", Checks::Green, "CLEAN", false)],
7160 vec![(true, "")],
7161 );
7162 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7163 .await
7164 .unwrap();
7165 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7167 assert_eq!(state.status, RunStatus::Merged);
7168 }
7169
7170 #[tokio::test]
7171 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7172 let mut state = landing_state();
7173 let forge = Scripted::new(
7174 vec![seen("a", Checks::Green, "CLEAN", false)],
7175 std::iter::repeat_n((true, ""), 100).collect(),
7176 );
7177 *forge.queued.lock().unwrap() = true;
7178 let task = async {
7179 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7180 .await
7181 .unwrap();
7182 };
7183 task.await;
7186 assert_ne!(state.status, RunStatus::Merged);
7187 }
7188
7189 #[tokio::test]
7190 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7191 let mut state = landing_state();
7192 let forge = Scripted::new(
7193 vec![seen("a", Checks::Green, "CLEAN", false)],
7194 std::iter::repeat_n((true, ""), 100).collect(),
7195 );
7196 *forge.unreadable_after_merge.lock().unwrap() = true;
7197 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7198 .await
7199 .ok();
7200 assert_ne!(state.status, RunStatus::Merged);
7201 }
7202
7203 #[tokio::test]
7204 async fn a_task_public_head_is_awaited_on_land_entry() {
7205 let mut state = landing_state();
7206 state.pr_head = Some("magi/task-abcd-1234".into());
7207 state.pr_pushed_head = Some("new".into());
7208 let forge = Scripted::new(
7209 vec![
7210 seen("old", Checks::Green, "CLEAN", false),
7211 seen("new", Checks::Green, "CLEAN", false),
7212 ],
7213 vec![(true, "")],
7214 );
7215 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7216 .await
7217 .unwrap();
7218 assert_eq!(
7219 forge.calls(),
7220 ["view", "poll", "view", "view", "merge", "view"]
7221 );
7222 assert_eq!(state.status, RunStatus::Merged);
7223 }
7224
7225 #[tokio::test]
7226 async fn a_superseded_public_head_owner_cannot_reenter_land() {
7227 let mut state = landing_state();
7228 state.pr_taken_over_by = Some("next".into());
7229 let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7230 assert!(
7231 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7232 .await
7233 .is_err()
7234 );
7235 assert!(forge.calls().is_empty());
7236 }
7237
7238 #[tokio::test]
7239 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7240 let mut state = landing_state();
7241 let forge = Scripted::new(
7242 vec![
7243 seen("old", Checks::Green, "CLEAN", true),
7244 seen("old", Checks::Green, "CLEAN", true),
7246 seen("new", Checks::Pending, "BLOCKED", true),
7247 seen("new", Checks::Green, "CLEAN", true),
7248 ],
7249 vec![(true, "")],
7250 );
7251 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7252 head: "NEW".to_owned(),
7253 });
7254 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7255 .await
7256 .unwrap();
7257 assert_eq!(
7258 forge.calls(),
7259 [
7260 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7261 "view"
7262 ]
7263 );
7264 assert_eq!(state.status, RunStatus::Merged);
7265 }
7266
7267 #[tokio::test]
7268 async fn a_head_that_never_arrives_stops_naming_both_commits() {
7269 let mut state = landing_state();
7270 let forge = Scripted::new(
7271 vec![
7272 seen("old", Checks::Green, "CLEAN", true),
7273 seen("someone-elses", Checks::Green, "CLEAN", true),
7274 ],
7275 vec![],
7276 );
7277 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7278 head: "mine".to_owned(),
7279 });
7280 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7281 .await
7282 .unwrap();
7283 assert!(!forge.calls().contains(&"merge"));
7284 let why = state.merge.as_ref().unwrap().detail.clone();
7285 assert!(
7286 why.contains("mine") && why.contains("someone-elses"),
7287 "{why}"
7288 );
7289 assert_eq!(state.status, RunStatus::Blocked);
7290 }
7291
7292 #[tokio::test]
7293 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7294 let mut state = landing_state();
7295 let forge = Scripted::new(
7296 vec![
7297 seen("a", Checks::Green, "CLEAN", false),
7298 seen("a", Checks::Green, "CLEAN", false),
7299 seen("a", Checks::Pending, "BLOCKED", false),
7300 seen("a", Checks::Pending, "BLOCKED", false),
7301 seen("a", Checks::Green, "CLEAN", false),
7302 ],
7303 vec![(false, REFUSED), (true, "")],
7304 );
7305 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7306 .await
7307 .unwrap();
7308 assert_eq!(
7309 forge.calls(),
7310 [
7311 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7312 "view"
7313 ]
7314 );
7315 assert_eq!(state.status, RunStatus::Merged);
7316 }
7317
7318 #[tokio::test]
7319 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7320 let mut state = landing_state();
7321 let forge = Scripted::new(
7322 vec![
7323 seen("a", Checks::Green, "CLEAN", false),
7324 seen("a", Checks::Green, "BLOCKED", false),
7325 ],
7326 vec![(false, REFUSED), (false, REFUSED)],
7327 );
7328 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7329 .await
7330 .unwrap();
7331 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7333 let why = state.merge.as_ref().unwrap().detail.clone();
7334 assert!(
7335 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7336 "{why}"
7337 );
7338 assert_eq!(state.status, RunStatus::Blocked);
7339 }
7340
7341 #[test]
7342 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7343 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7344 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7345 assert_eq!(bound_head("old", "old", Some("new")), None);
7347 assert_eq!(bound_head("new", "old", Some("new")), None);
7349 assert_eq!(bound_head("new", "old", None), None);
7350 assert_eq!(bound_head("", "", None), None);
7352 assert_eq!(bound_head("", "", Some("new")), None);
7353 assert_eq!(bound_head("abc", "", None), None);
7354 }
7355
7356 fn view_json(head: &str) -> String {
7357 format!(
7358 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7359 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7360 "reviews":[],"comments":[]}}"#
7361 )
7362 }
7363
7364 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7365 format!(
7366 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7367 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7368 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7369 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7370 )
7371 }
7372
7373 #[test]
7374 fn rollup_is_bound_to_the_commit_in_the_same_node() {
7375 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7378 assert_eq!(s.rollup_head, "old");
7379 assert_eq!(s.pr.checks, Checks::Red);
7380 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7381 assert_eq!(s.failing_urls.len(), 1);
7382 }
7383
7384 #[test]
7385 fn checks_come_from_the_node_not_the_view() {
7386 let view = view_json("new").replace(
7387 r#""reviews""#,
7388 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7389 );
7390 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7391 assert_eq!(s.pr.checks, Checks::Green);
7392 assert!(s.pr.failing.is_empty());
7393 assert_eq!(
7394 bound_head(&s.head, &s.rollup_head, Some("new")),
7395 Some("new")
7396 );
7397 }
7398
7399 #[test]
7400 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7401 for node in [
7402 None,
7403 Some("not json".to_owned()),
7404 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7405 Some(node_json("new", "SUCCESS", true)),
7406 ] {
7407 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7408 assert!(s.rollup_head.is_empty());
7409 assert_eq!(s.pr.checks, Checks::Unknown);
7410 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7411 }
7412 }
7413
7414 #[test]
7415 fn the_merge_command_is_pinned_to_the_observed_head() {
7416 let argv = merge_argv_at(7, "feat: x", "deadbeef");
7417 let at = argv
7418 .iter()
7419 .position(|a| a == "--match-head-commit")
7420 .unwrap();
7421 assert_eq!(argv[at + 1], "deadbeef");
7422 }
7423
7424 #[tokio::test]
7425 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7426 let mut state = landing_state();
7427 let mut stale = seen("new", Checks::Red, "CLEAN", false);
7430 stale.rollup_head = "old".to_owned();
7431 let forge = Scripted::new(
7432 vec![seen("old", Checks::Green, "CLEAN", true), stale],
7433 vec![],
7434 );
7435 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7436 head: "new".to_owned(),
7437 });
7438 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7439 .await
7440 .unwrap();
7441 assert!(!forge.calls().contains(&"merge"));
7442 assert_eq!(state.status, RunStatus::Blocked);
7443 let why = state.merge.as_ref().unwrap().detail.clone();
7444 assert!(why.contains("new") && why.contains("old"), "{why}");
7445 }
7446
7447 #[test]
7448 fn a_refusal_read_against_another_commits_checks_is_pending() {
7449 let mut after = seen("a", Checks::Green, "BLOCKED", false);
7450 after.rollup_head = "old".to_owned();
7451 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7452 }
7453
7454 #[tokio::test]
7455 async fn a_matching_head_with_red_non_required_checks_still_merges() {
7456 let mut state = landing_state();
7457 let forge = Scripted::new(
7458 vec![seen("a", Checks::Red, "CLEAN", false)],
7459 vec![(true, "")],
7460 );
7461 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7462 .await
7463 .unwrap();
7464 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7465 assert_eq!(state.status, RunStatus::Merged);
7466 }
7467
7468 #[tokio::test]
7469 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7470 let mut state = landing_state();
7471 let forge = Scripted::new(
7472 vec![
7473 seen("a", Checks::Green, "CLEAN", false),
7474 seen("a", Checks::Green, "CLEAN", false),
7475 seen("b", Checks::Green, "BLOCKED", false),
7477 seen("b", Checks::Green, "CLEAN", false),
7478 ],
7479 vec![(false, REFUSED), (true, "")],
7480 );
7481 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7482 .await
7483 .unwrap();
7484 assert_eq!(
7485 forge.calls(),
7486 [
7487 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7488 ]
7489 );
7490 assert_eq!(state.status, RunStatus::Merged);
7491 }
7492
7493 fn merged_view(head: &str) -> Seen {
7494 let mut m = seen(head, Checks::Green, "CLEAN", false);
7495 m.pr.state = PrLifecycle::Merged;
7496 m
7497 }
7498
7499 fn has(argv: &[String], flag: &str) -> bool {
7500 argv.iter().any(|a| a == flag)
7501 }
7502
7503 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7504 let at = argv.iter().position(|a| a == flag)?;
7505 argv.get(at + 1).map(String::as_str)
7506 }
7507
7508 const URL: &str = "https://github.com/o/r/pull/42";
7509
7510 #[tokio::test]
7511 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7512 let mut state = landing_state();
7513 let forge = Scripted::new(
7514 vec![
7515 seen("abc", Checks::Green, "CLEAN", false),
7516 seen("abc", Checks::Green, "CLEAN", false),
7517 ],
7518 vec![(true, "")],
7519 );
7520 land_with(&mut state, URL, &forge).await.unwrap();
7521 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7522 let argv = &forge.argvs()[0];
7523 assert!(has(argv, "--squash") && has(argv, "--subject"));
7524 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7525 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7526 assert_eq!(state.status, RunStatus::Merged);
7527 assert!(state.land_armed_head.is_none());
7528 }
7529
7530 #[tokio::test]
7531 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7532 let mut state = landing_state();
7533 state.land_armed_head = Some("a".to_owned());
7534 let forge = Scripted::new(
7535 vec![seen("a", Checks::Green, "CLEAN", false)],
7536 vec![(true, ""), (true, "")],
7537 );
7538 land_with(&mut state, URL, &forge).await.unwrap();
7539 let argvs = forge.argvs();
7540 assert!(has(&argvs[0], "--disable-auto"));
7541 assert!(!has(&argvs[1], "--auto"));
7542 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7543 assert_eq!(state.status, RunStatus::Merged);
7544 assert!(state.land_armed_head.is_none());
7545 }
7546
7547 #[tokio::test]
7548 async fn an_approval_never_carries_over_to_a_new_head() {
7549 crate::run::pin_test_home();
7550 let mut state = run_state();
7551 state.config.graph.land_approval = true;
7552 let pr = green_pr();
7553 let store = ask::Questions::open();
7554
7555 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7556 .await
7557 .unwrap();
7558 let mut q = store
7559 .list()
7560 .into_iter()
7561 .find(|q| q.run == state.id)
7562 .unwrap();
7563 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7564 store.put(&mut q).unwrap();
7565 assert_eq!(
7566 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7567 .await
7568 .unwrap(),
7569 ApprovalGate::Approved,
7570 "the same head keeps its approval"
7571 );
7572
7573 assert_eq!(
7575 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7576 .await
7577 .unwrap(),
7578 ApprovalGate::Pending
7579 );
7580 let all: Vec<_> = store
7581 .list()
7582 .into_iter()
7583 .filter(|q| q.run == state.id)
7584 .collect();
7585 assert_eq!(all.len(), 2);
7586
7587 state.land_approval = None;
7589 assert_eq!(
7590 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7591 .await
7592 .unwrap(),
7593 ApprovalGate::Pending
7594 );
7595 let open = store
7596 .list()
7597 .into_iter()
7598 .filter(|q| q.run == state.id && q.status.open())
7599 .count();
7600 assert_eq!(open, 1, "the superseded question was retired");
7601 }
7602
7603 #[tokio::test]
7604 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7605 let mut state = landing_state();
7606 let forge = Scripted::new(
7607 vec![
7608 seen("a", Checks::Green, "CLEAN", false),
7609 seen("b", Checks::Green, "CLEAN", false),
7611 ],
7612 vec![(true, "")],
7613 );
7614 land_with(&mut state, URL, &forge).await.unwrap();
7615 let argvs = forge.argvs();
7616 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7617 assert!(!has(&argvs[0], "--auto"));
7618 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7619 assert_eq!(state.status, RunStatus::Merged);
7620 }
7621
7622 fn passing(label: &str) -> CheckInfo {
7623 CheckInfo {
7624 label: label.to_owned(),
7625 verdict: Verdict::Pass,
7626 required: Some(false),
7627 run: None,
7628 }
7629 }
7630
7631 fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7632 (label.to_owned(), run.map(str::to_owned))
7633 }
7634
7635 fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7636 LandReruns {
7637 head: head.to_owned(),
7638 runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7639 }
7640 }
7641
7642 #[test]
7643 fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7644 let f = [failing("test (macos)", Some("7"))];
7645 assert_eq!(
7646 rerun_step(&f, None, "h", 100),
7647 RerunStep::Rerun(vec!["7".to_owned()])
7648 );
7649 let rec = reran("h", &[("7", 100)]);
7650 let grace = crate::release_watch::RERUN_GRACE;
7651 assert_eq!(
7652 rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7653 RerunStep::Grace
7654 );
7655 assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7656 }
7657
7658 #[test]
7659 fn a_failure_without_a_run_id_goes_to_the_fixer() {
7660 let f = [failing("a", Some("7")), failing("ci/external", None)];
7661 assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7662 assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7663 }
7664
7665 #[test]
7666 fn a_moved_head_forgets_the_reruns() {
7667 let f = [failing("a", Some("7"))];
7668 let rec = reran("old", &[("7", 100)]);
7669 assert_eq!(
7670 rerun_step(&f, Some(&rec), "new", 101),
7671 RerunStep::Rerun(vec!["7".to_owned()])
7672 );
7673 }
7674
7675 #[test]
7676 fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7677 let f = [
7678 failing("a", Some("7")),
7679 failing("b", Some("7")),
7680 failing("c", Some("8")),
7681 ];
7682 assert_eq!(
7683 rerun_step(&f, None, "h", 0),
7684 RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7685 );
7686 let rec = reran("h", &[("7", 0)]);
7687 assert_eq!(
7688 rerun_step(&f, Some(&rec), "h", 1),
7689 RerunStep::Rerun(vec!["8".to_owned()])
7690 );
7691 }
7692
7693 #[test]
7694 fn only_blocking_failures_are_candidates_for_a_rerun() {
7695 let check = |label: &str, verdict, required, run: &str| CheckInfo {
7696 label: label.to_owned(),
7697 verdict,
7698 required,
7699 run: Some(run.to_owned()),
7700 };
7701 let got = rerunnable(&[
7702 check("passed", Verdict::Pass, Some(true), "1"),
7703 check("optional", Verdict::Fail, Some(false), "2"),
7704 check("required", Verdict::Fail, Some(true), "3"),
7705 check("unknown", Verdict::Fail, None, "4"),
7706 ]);
7707 let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7708 assert_eq!(labels, ["required", "unknown"]);
7709 }
7710
7711 fn red(head: &str, run: Option<&str>) -> Seen {
7712 let mut s = seen(head, Checks::Red, "BLOCKED", false);
7713 s.pr.failing = vec!["test (macos)".to_owned()];
7714 s.contexts = vec![CheckInfo {
7715 label: "test (macos)".to_owned(),
7716 verdict: Verdict::Fail,
7717 required: Some(true),
7718 run: run.map(str::to_owned),
7719 }];
7720 s
7721 }
7722
7723 const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7724
7725 #[tokio::test]
7726 async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7727 let mut state = landing_state();
7728 let forge = Scripted::new(
7729 vec![
7730 red("a", Some("7")),
7731 seen("a", Checks::Pending, "BLOCKED", false),
7732 seen("a", Checks::Green, "CLEAN", false),
7733 ],
7734 vec![(true, "")],
7735 );
7736 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7737 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7738 assert!(!forge.calls().contains(&"fix"));
7739 assert_eq!(state.status, RunStatus::Merged);
7740 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7741 let argvs = forge.argvs();
7742 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7743 assert!(
7744 state
7745 .events
7746 .iter()
7747 .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7748 );
7749 }
7750
7751 #[tokio::test]
7752 async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7753 let mut state = landing_state();
7754 let forge = Scripted::new(
7755 vec![
7756 red("a", Some("7")),
7757 seen("a", Checks::Green, "CLEAN", false),
7758 ],
7759 vec![(true, "")],
7760 );
7761 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7762 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7764 assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7765 let calls = forge.calls();
7766 let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7767 let fix_at = calls.iter().position(|c| *c == "fix");
7768 assert!(fix_at.is_none_or(|f| f > rerun_at));
7769 }
7770
7771 #[tokio::test]
7772 async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7773 let mut state = landing_state();
7774 let mut v = red("a", Some("7"));
7775 v.pr.checks = Checks::Pending;
7776 v.contexts.push(CheckInfo {
7777 label: "slow".to_owned(),
7778 verdict: Verdict::Pending,
7779 required: Some(true),
7780 run: Some("8".to_owned()),
7781 });
7782 let forge = Scripted::new(
7783 vec![v, seen("a", Checks::Green, "CLEAN", false)],
7784 vec![(true, "")],
7785 );
7786 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7787 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7788 assert_eq!(state.status, RunStatus::Merged);
7789 }
7790
7791 #[test]
7792 fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7793 let mut v = red("a", Some("7"));
7794 v.contexts.push(CheckInfo {
7795 label: "same run, still going".to_owned(),
7796 verdict: Verdict::Pending,
7797 required: Some(true),
7798 run: Some("7".to_owned()),
7799 });
7800 assert!(rerunnable(&v.contexts).is_empty());
7801 }
7802
7803 #[tokio::test]
7804 async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7805 let mut state = landing_state();
7806 state.config.graph.land_rounds = 0;
7807 let forge = Scripted::new(
7808 vec![
7809 red("a", Some("7")),
7810 seen("a", Checks::Green, "CLEAN", false),
7811 ],
7812 vec![(true, "")],
7813 );
7814 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7815 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7816 assert_eq!(state.status, RunStatus::Merged);
7817 assert!(!forge.calls().contains(&"fix"));
7818 assert_eq!(state.pr.as_ref().unwrap().round, 0);
7819 }
7820
7821 #[tokio::test]
7822 async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7823 let mut state = landing_state();
7824 state.config.graph.land_rounds = 1;
7825 let forge = Scripted::new(
7826 vec![
7827 seen("a", Checks::Green, "CLEAN", true),
7828 red("b", Some("7")),
7829 seen("b", Checks::Green, "CLEAN", false),
7830 ],
7831 vec![(true, "")],
7832 );
7833 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7834 head: "b".to_owned(),
7835 });
7836 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7837 assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7838 assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7839 assert_eq!(state.pr.as_ref().unwrap().round, 1);
7840 assert_eq!(state.status, RunStatus::Merged);
7841 assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7842 assert_eq!(
7843 value_of(&forge.argvs()[0], "--match-head-commit"),
7844 Some("b")
7845 );
7846 }
7847
7848 #[tokio::test]
7849 async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7850 let mut state = landing_state();
7851 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7852 *forge.rerun_fails.lock().unwrap() = true;
7853 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7854 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7855 assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7856 assert_eq!(
7857 forge.calls().iter().filter(|c| **c == "fix").count(),
7858 1,
7859 "{:?}",
7860 forge.calls()
7861 );
7862 }
7863
7864 #[tokio::test]
7865 async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7866 let mut state = landing_state();
7867 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7868 state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7870 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7871 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7872 assert!(forge.reruns.lock().unwrap().is_empty());
7873 }
7874
7875 #[tokio::test]
7876 async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7877 let mut state = landing_state();
7878 state.config.graph.rerun_flaky_checks = false;
7879 let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7880 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7881 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7882 assert!(forge.reruns.lock().unwrap().is_empty());
7883 assert!(forge.calls().contains(&"fix"));
7884
7885 let mut state = landing_state();
7886 let forge = Scripted::new(vec![red("a", None)], vec![]);
7887 *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7888 land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7889 assert!(forge.reruns.lock().unwrap().is_empty());
7890 assert!(forge.calls().contains(&"fix"));
7891 }
7892
7893 fn names(xs: &[&str]) -> BTreeSet<String> {
7894 xs.iter().map(|x| (*x).to_owned()).collect()
7895 }
7896
7897 #[test]
7898 fn a_required_check_the_rollup_never_listed_is_named() {
7899 let req = names(&["build"]);
7900 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7901 assert!(why.contains("never reported: build"), "{why}");
7902 assert!(!why.contains("probably waiting for a review"), "{why}");
7903 }
7904
7905 #[test]
7906 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7907 let why = waiting_on("BLOCKED", &[passing("review")], None);
7908 assert!(why.contains("could not be read"), "{why}");
7909 assert!(!why.contains("probably waiting for a review"), "{why}");
7910 }
7911
7912 #[test]
7913 fn all_required_reported_keeps_the_review_guess() {
7914 let req = names(&["build"]);
7915 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7916 assert!(why.contains("probably waiting for a review"), "{why}");
7917 assert!(!why.contains("never reported"), "{why}");
7918 }
7919
7920 #[test]
7921 fn required_names_match_the_rollup_ignoring_case_only() {
7922 let req = names(&["Build"]);
7923 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7924 assert!(!why.contains("never reported"), "{why}");
7925 }
7926
7927 #[test]
7928 fn required_contexts_are_read_from_protection_and_rulesets() {
7929 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7930 assert_eq!(
7931 parse_classic_required(classic),
7932 Some(names(&["build", "lint"]))
7933 );
7934 let rules = r#"[{"type":"pull_request","parameters":{}},
7935 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7936 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7937 assert_eq!(parse_ruleset_required("nope"), None);
7938 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7939 }
7940
7941 #[test]
7942 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7943 let shown = BTreeSet::new();
7944 let ok = seen("a", Checks::Green, "CLEAN", false);
7945 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7946 assert!(guard(Some(&ok)));
7947 assert!(!guard(None));
7948 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7949 let mut stale = ok.clone();
7950 stale.rollup_head = "old".to_owned();
7951 assert!(!guard(Some(&stale)));
7952 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7953 assert!(!guard(Some(&merged_view("a"))));
7954 }
7955
7956 #[test]
7957 fn the_rollup_node_carries_whether_each_check_is_required() {
7958 let node = node_json("new", "SUCCESS", false)
7959 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7960 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7961 assert_eq!(s.contexts.len(), 1);
7962 assert_eq!(s.contexts[0].required, Some(true));
7963 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7964 assert_eq!(s.contexts[0].required, None);
7965 }
7966
7967 #[tokio::test]
7968 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7969 let mut state = landing_state();
7970 state.land_armed_head = Some("a".to_owned());
7971 let forge = Scripted::new(
7972 vec![seen("a", Checks::Green, "CLEAN", true)],
7973 vec![(false, "disable exploded")],
7974 );
7975 land_with(&mut state, URL, &forge).await.unwrap();
7976 assert!(!forge.calls().contains(&"fix"));
7977 assert_eq!(state.status, RunStatus::Blocked);
7978 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7979 let why = state.merge.as_ref().unwrap().detail.clone();
7980 assert!(why.contains("disable exploded"), "{why}");
7981 }
7982}