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