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::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{ContestedHandoff, LandApproval, MergeOutcome, RunState, RunStatus, tail};
58
59pub const POLL: Duration = Duration::from_secs(30);
65
66pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86const LOG_TAIL: usize = 4_000;
89
90const MAX_LOGS: usize = 3;
93
94pub const MARKER: &str = "<!-- magi:land -->";
100
101const NOT_A_REVIEW: [&str; 3] = [
110 "skip review by coderabbit.ai",
111 "summarize by coderabbit.ai",
112 "<!-- tips_start -->",
113];
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118 Open,
120 Merged,
122 Closed,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129 Pending,
131 Green,
134 Red,
136 Unknown,
138}
139
140impl PrLifecycle {
141 pub fn as_str(self) -> &'static str {
143 match self {
144 Self::Open => "open",
145 Self::Merged => "merged",
146 Self::Closed => "closed",
147 }
148 }
149}
150
151impl Checks {
152 pub fn as_str(self) -> &'static str {
154 match self {
155 Self::Pending => "pending",
156 Self::Green => "green",
157 Self::Red => "red",
158 Self::Unknown => "unknown",
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166 pub author: String,
168 pub path: Option<String>,
170 pub line: Option<u64>,
172 pub body: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179 pub url: String,
181 pub number: u64,
183 pub state: PrLifecycle,
185 pub checks: Checks,
187 pub failing: Vec<String>,
189 pub review_comments: Vec<ReviewComment>,
191 pub blocking: Blocking,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209 No,
211 Yes,
213 Conflict,
215 Unsaid,
219}
220
221impl Blocking {
222 fn of(raw: &str) -> Self {
224 match raw.to_ascii_uppercase().as_str() {
225 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228 "DIRTY" => Self::Conflict,
229 "" | "UNKNOWN" => Self::Unsaid,
230 _ => Self::Yes,
232 }
233 }
234
235 #[must_use]
237 pub fn stops_a_merge(self) -> bool {
238 !matches!(self, Self::No)
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245 Wait,
247 Rebase,
255 Fix {
257 reason: String,
259 },
260 Merge,
262 Done {
264 merged: bool,
266 },
267 GiveUp {
269 reason: String,
271 },
272}
273
274pub(crate) fn merged_after_all(
296 argv: &[String],
297 stderr: &str,
298 after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300 if after? != PrLifecycle::Merged {
301 return None;
302 }
303 Some(MergeOutcome {
304 mode: MergeMode::Pr,
305 ok: true,
306 detail: format!(
307 "gh {} (the command reported `{}`, but the pull request is merged)",
308 argv.join(" "),
309 stderr.trim()
310 ),
311 empty: false,
312 })
313}
314
315pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
335 match pr.state {
336 PrLifecycle::Merged => return Step::Done { merged: true },
337 PrLifecycle::Closed => return Step::Done { merged: false },
338 PrLifecycle::Open => {}
339 }
340
341 if pr.blocking == Blocking::Conflict {
344 return Step::Rebase;
345 }
346
347 let spent = round >= budget;
348 match pr.checks {
349 Checks::Pending => Step::Wait,
350 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
351 Checks::Unknown => Step::GiveUp {
352 reason: format!(
353 "no check status is readable on the pull request after {} minute(s); \
354 refusing to merge on a guess",
355 CHECKS_GRACE.as_secs() / 60
356 ),
357 },
358 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
365 Checks::Red => {
366 let what = format!(
367 "{} check(s) failing: {}",
368 pr.failing.len(),
369 pr.failing.join(", ")
370 );
371 if spent {
372 Step::GiveUp {
373 reason: format!("{what} — still red after {budget} fix round(s)"),
374 }
375 } else {
376 Step::Fix { reason: what }
377 }
378 }
379 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
380 Checks::Green => {
381 let what = format!(
382 "checks are green but {} review comment(s) are unresolved: {}",
383 pr.review_comments.len(),
384 authors(&pr.review_comments)
385 );
386 if spent {
387 Step::GiveUp {
388 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
389 }
390 } else {
391 Step::Fix { reason: what }
392 }
393 }
394 }
395}
396
397fn authors(comments: &[ReviewComment]) -> String {
399 let mut seen: Vec<&str> = Vec::new();
400 for c in comments {
401 if !seen.contains(&c.author.as_str()) {
402 seen.push(&c.author);
403 }
404 }
405 seen.join(", ")
406}
407
408pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
412 vec![
413 "pr".to_owned(),
414 "merge".to_owned(),
415 number.to_string(),
416 "--squash".to_owned(),
417 "--delete-branch".to_owned(),
418 "--subject".to_owned(),
419 subject.to_owned(),
420 ]
421}
422
423pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
429 let mut argv = merge_argv(number, subject);
430 argv.push("--match-head-commit".to_owned());
431 argv.push(head.to_owned());
432 argv
433}
434
435pub fn disable_automerge_argv(number: u64) -> Vec<String> {
440 ["pr", "merge", &number.to_string(), "--disable-auto"]
441 .map(str::to_owned)
442 .to_vec()
443}
444
445fn direct_merge_is_safe(
451 fresh: Option<&Seen>,
452 approved_head: &str,
453 shown: &BTreeSet<String>,
454 round: usize,
455 budget: usize,
456 waited: Duration,
457) -> bool {
458 let Some(fresh) = fresh else {
459 return false;
460 };
461 if fresh.pr.state != PrLifecycle::Open {
462 return false;
463 }
464 let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
465 return false;
466 };
467 if !bound.eq_ignore_ascii_case(approved_head) {
468 return false;
469 }
470 let mut pr = fresh.pr.clone();
471 pr.review_comments.retain(|c| !shown.contains(&c.body));
472 decide(&pr, round, budget, waited) == Step::Merge
473}
474
475fn waiting_on(
482 merge_state: &str,
483 contexts: &[CheckInfo],
484 required_set: Option<&BTreeSet<String>>,
485) -> String {
486 let state = if merge_state.is_empty() {
487 "unknown"
488 } else {
489 merge_state
490 };
491 let tag = |c: &CheckInfo| match c.verdict {
492 Verdict::Fail => "failed",
493 _ => "pending",
494 };
495 let unsettled: Vec<&CheckInfo> = contexts
496 .iter()
497 .filter(|c| c.verdict != Verdict::Pass)
498 .collect();
499 let required: Vec<String> = unsettled
500 .iter()
501 .filter(|c| c.required == Some(true))
502 .map(|c| format!("{} ({})", c.label, tag(c)))
503 .collect();
504 let unknown: Vec<String> = unsettled
505 .iter()
506 .filter(|c| c.required.is_none())
507 .map(|c| format!("{} ({})", c.label, tag(c)))
508 .collect();
509 let never: Vec<&str> = required_set
513 .map(|set| {
514 set.iter()
515 .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
516 .map(String::as_str)
517 .collect()
518 })
519 .unwrap_or_default();
520 let mut out = format!("merge state: {state}");
521 if !never.is_empty() {
522 let _ = write!(
523 out,
524 "; required checks never reported: {}",
525 never.join(", ")
526 );
527 }
528 if !required.is_empty() {
529 let _ = write!(
530 out,
531 "; required checks not passing: {}",
532 required.join(", ")
533 );
534 }
535 if !unknown.is_empty() {
536 let _ = write!(
537 out,
538 "; whether these are required could not be read, so they may be: {}",
539 unknown.join(", ")
540 );
541 }
542 if required.is_empty() && unknown.is_empty() && never.is_empty() {
543 if required_set.is_some() {
544 out.push_str(
545 "; no required check is pending, failing or unreported, so GitHub is probably \
546 waiting for a review or another branch rule",
547 );
548 } else {
549 out.push_str(
550 "; the required check list could not be read, so a required check that was \
551 never reported cannot be ruled out",
552 );
553 }
554 }
555 out
556}
557
558pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
565 let title = pr_title.trim();
566 if !title.is_empty() && !title.starts_with("magi: candidate") {
567 return title.to_owned();
568 }
569 let first = instruction
570 .lines()
571 .map(str::trim)
572 .find(|l| !l.is_empty())
573 .unwrap_or("magi: land the winning candidate");
574 first.trim_start_matches(['#', ' ']).to_owned()
575}
576
577pub const APPROVE: &str = "merge";
579
580pub const HOLD: &str = "hold";
582
583pub const APPROVAL_NODE: &str = "land-approval";
589
590pub const DIFF_MAX_LINES: usize = 400;
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum Approval {
602 Merge,
604 Hold,
606}
607
608pub fn approval(answer: Option<&str>) -> Approval {
616 match answer {
617 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
618 _ => Approval::Hold,
619 }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624enum ApprovalGate {
625 Approved,
627 Held,
630 Pending,
632}
633
634fn esc(s: &str) -> String {
644 let mut out = String::with_capacity(s.len());
645 for c in s.chars() {
646 match c {
647 '&' => out.push_str("&"),
648 '<' => out.push_str("<"),
649 '>' => out.push_str(">"),
650 '"' => out.push_str("""),
651 '\'' => out.push_str("'"),
652 _ => out.push(c),
653 }
654 }
655 out
656}
657
658#[derive(Debug, Clone, PartialEq, Eq)]
660struct StatRow {
661 path: String,
662 added: Option<u64>,
664 removed: Option<u64>,
665}
666
667impl StatRow {
668 fn churn(&self) -> u64 {
671 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
672 }
673}
674
675fn parse_numstat(numstat: &str) -> Vec<StatRow> {
681 let mut rows: Vec<StatRow> = numstat
682 .lines()
683 .filter_map(|line| {
684 let mut parts = line.splitn(3, '\t');
685 let added = parts.next()?.trim();
686 let removed = parts.next()?.trim();
687 let path = parts.next()?.trim();
688 if path.is_empty() {
689 return None;
690 }
691 Some(StatRow {
692 path: path.to_owned(),
693 added: added.parse().ok(),
694 removed: removed.parse().ok(),
695 })
696 })
697 .collect();
698 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
701 rows
702}
703
704fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
713 if line.starts_with("+++") || line.starts_with("---") {
714 (" ", "color:#57606a;font-weight:600", line)
715 } else if let Some(body) = line.strip_prefix('+') {
716 ("+", "background:#e6ffec;color:#0a3622", body)
717 } else if let Some(body) = line.strip_prefix('-') {
718 ("-", "background:#ffebe9;color:#5c1a17", body)
719 } else if line.starts_with("@@") {
720 ("~", "background:#eef2ff;color:#3730a3", line)
721 } else if let Some(body) = line.strip_prefix(' ') {
722 (" ", "", body)
723 } else {
724 (" ", "color:#57606a;font-weight:600", line)
725 }
726}
727
728struct Words {
737 html_lang: &'static str,
738 task: &'static str,
739 what_changed: &'static str,
740 review_verdict: &'static str,
741 reviewer: &'static str,
742 reviewer_no_answer: &'static str,
743 checks: &'static str,
744 nothing_failing: &'static str,
745 files_changed: &'static str,
746 commits: &'static str,
747 no_commits: &'static str,
748 comments: &'static str,
749 no_comments: &'static str,
750 diff: &'static str,
751 truncated: &'static str,
752 lands_as: &'static str,
753}
754
755const EN: Words = Words {
756 html_lang: "en",
757 task: "Task",
758 what_changed: "What changed",
759 review_verdict: "Review verdict",
760 reviewer: "Reviewer",
761 reviewer_no_answer: "produced no answer",
762 checks: "Checks",
763 nothing_failing: "Nothing failing.",
764 files_changed: "file(s) changed",
765 commits: "Commits being squashed",
766 no_commits: "No commit subjects could be read from the branch.",
767 comments: "Review comments",
768 no_comments: "Nothing outstanding at this observation.",
769 diff: "Diff",
770 truncated: "Truncated",
771 lands_as: "They land as one commit titled",
772};
773
774const JA: Words = Words {
775 html_lang: "ja",
776 task: "タスク",
777 what_changed: "変更内容",
778 review_verdict: "レビューの結論",
779 reviewer: "レビュアー",
780 reviewer_no_answer: "回答なし",
781 checks: "チェック",
782 nothing_failing: "失敗しているものはありません。",
783 files_changed: "ファイル変更",
784 commits: "squash されるコミット",
785 no_commits: "ブランチからコミット件名を読めませんでした。",
786 comments: "レビューコメント",
787 no_comments: "この時点で未対応のものはありません。",
788 diff: "差分",
789 truncated: "省略",
790 lands_as: "これらは次の件名の1コミットとして入ります:",
791};
792
793impl Words {
794 fn lands_as_tail(&self) -> &'static str {
798 if self.html_lang == "ja" {
799 "。この件名も承認の対象です。"
800 } else {
801 ", which you are approving too."
802 }
803 }
804
805 fn approval_summary(&self, number: u64, subject: &str) -> String {
807 if self.html_lang == "ja" {
808 format!("プルリクエスト #{number} をマージ: {subject}")
809 } else {
810 format!("merge pull request #{number}: {subject}")
811 }
812 }
813
814 fn approval_detail(
816 &self,
817 url: &str,
818 base: &str,
819 subject: &str,
820 contested: Option<&ContestedHandoff>,
821 ) -> String {
822 let body = if self.html_lang == "ja" {
823 format!(
824 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
825 できる状態です。差分の要約・パッチ・squash されるコミットは\
826 下のパネルにあります。"
827 )
828 } else {
829 format!(
830 "{url} is green and ready to squash into `{base}` as `{subject}`. \
831 The panel holds the diffstat, the patch and the commits being squashed."
832 )
833 };
834 match contested {
835 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
836 None => body,
837 }
838 }
839
840 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
843 const SHOWN: usize = 5;
844 const TITLE_CHARS: usize = 100;
845 let ja = self.html_lang == "ja";
846 let mut out = if ja {
847 format!(
848 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
849 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
850 )
851 } else {
852 format!(
853 "{url} is held for approval although merge approvals are off: the \
854 review ended with blocking findings still open and a reviewer \
855 voting reject.\n"
856 )
857 };
858 for f in c.findings.iter().take(SHOWN) {
859 let at = match (&f.file, f.line) {
860 (Some(file), Some(line)) => format!("{file}:{line}"),
861 (Some(file), None) => file.clone(),
862 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
863 };
864 let title: String = f.title.chars().take(TITLE_CHARS).collect();
865 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
866 }
867 if c.findings.len() > SHOWN {
868 let more = c.findings.len() - SHOWN;
869 let _ = writeln!(
870 out,
871 "{}",
872 if ja {
873 format!("- ほか {more} 件")
874 } else {
875 format!("- and {more} more")
876 }
877 );
878 }
879 let seats: Vec<String> = c
880 .rejecters
881 .iter()
882 .map(|(seat, agent)| format!("#{seat} ({agent})"))
883 .collect();
884 let _ = write!(
885 out,
886 "{} {}",
887 if ja {
888 "却下したレビュアー:"
889 } else {
890 "Rejected by reviewer:"
891 },
892 seats.join(", ")
893 );
894 out
895 }
896
897 fn truncated_note(
899 &self,
900 omitted: usize,
901 total: usize,
902 shown: usize,
903 where_: &str,
904 base: &str,
905 head: &str,
906 ) -> String {
907 if self.html_lang == "ja" {
908 format!(
909 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
910 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
911 プルリクエストにあります。"
912 )
913 } else {
914 format!(
915 "{omitted} of {total} diff lines omitted after the first {shown}. \
916 The whole patch is in <code>{where_}</code> \
917 (<code>git diff {base}...{head}</code>) and on the pull request."
918 )
919 }
920 }
921}
922
923fn words(language: &str) -> &'static Words {
926 if crate::lang::is_japanese(language) {
927 &JA
928 } else {
929 &EN
930 }
931}
932
933pub fn approval_panel(
945 state: &RunState,
946 pr: &PrState,
947 diffstat: &str,
948 diff: &str,
949 commits: &[String],
950 subject: &str,
951) -> String {
952 let rows = parse_numstat(diffstat);
953 let w = words(&state.config.graph.language);
954 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
955
956 let _ = writeln!(
957 h,
958 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
959 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
960 w.html_lang
961 );
962 let _ = writeln!(
963 h,
964 "<title>merge #{} — {}</title>\n</head>",
965 pr.number,
966 esc(subject)
967 );
968 h.push_str(
969 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
970 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
971 word-break:break-word\">\n",
972 );
973
974 let _ = writeln!(
976 h,
977 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
978 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
979 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
980 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
981 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
982 pr.number,
983 esc(&state.base_branch),
984 esc(subject),
985 esc(&state.id),
986 esc(&pr.url),
987 esc(&pr.url),
988 );
989
990 let _ = writeln!(
993 h,
994 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
995 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
996 w.task,
997 esc(&state.instruction)
998 );
999
1000 if let Some(summary) = state
1002 .winner()
1003 .map(|c| c.summary.as_str())
1004 .filter(|s| !s.is_empty())
1005 {
1006 let _ = writeln!(
1007 h,
1008 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1009 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1010 w.what_changed,
1011 esc(summary)
1012 );
1013 }
1014
1015 if let Some(round) = state.reviews.last() {
1018 let _ = writeln!(
1019 h,
1020 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1021 w.review_verdict
1022 );
1023 for r in &round.reviews {
1024 let body = match &r.failed {
1031 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1032 None => esc(&r.summary),
1033 };
1034 let _ = writeln!(
1035 h,
1036 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1037 border-radius:6px\">\
1038 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1039 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1040 w.reviewer,
1041 r.reviewer,
1042 esc(&r.agent),
1043 body,
1044 );
1045 }
1046 }
1047
1048 let _ = writeln!(
1049 h,
1050 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1051 w.checks,
1052 esc(pr.checks.as_str())
1053 );
1054 if pr.failing.is_empty() {
1055 let _ = writeln!(
1056 h,
1057 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1058 w.nothing_failing
1059 );
1060 } else {
1061 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1062 for f in &pr.failing {
1063 let _ = writeln!(h, "<li>{}</li>", esc(f));
1064 }
1065 h.push_str("</ul>\n");
1066 }
1067
1068 let _ = writeln!(
1071 h,
1072 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1073 rows.len(),
1074 w.files_changed
1075 );
1076 h.push_str(
1077 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1078 <thead><tr>\
1079 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1080 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1081 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1082 </th></tr></thead>\n<tbody>\n",
1083 );
1084 let mut total_added = 0u64;
1085 let mut total_removed = 0u64;
1086 for r in &rows {
1087 total_added += r.added.unwrap_or(0);
1088 total_removed += r.removed.unwrap_or(0);
1089 let cell = |n: Option<u64>| match n {
1090 Some(n) => n.to_string(),
1091 None => "bin".to_owned(),
1092 };
1093 let _ = writeln!(
1094 h,
1095 "<tr>\
1096 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1097 font-family:ui-monospace,monospace\">{}</td>\
1098 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1099 color:#0a3622\">{}</td>\
1100 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1101 color:#5c1a17\">{}</td></tr>",
1102 esc(&r.path),
1103 cell(r.added),
1104 cell(r.removed),
1105 );
1106 }
1107 let _ = writeln!(
1108 h,
1109 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1110 <td style=\"padding:4px 2px\">total</td>\
1111 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1112 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1113 </tr></tfoot>\n</table>"
1114 );
1115
1116 let _ = writeln!(
1118 h,
1119 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1120 w.commits
1121 );
1122 if commits.is_empty() {
1123 h.push_str(&format!(
1124 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1125 w.no_commits
1126 ));
1127 } else {
1128 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1129 for c in commits {
1130 let _ = writeln!(h, "<li>{}</li>", esc(c));
1131 }
1132 h.push_str("</ol>\n");
1133 }
1134 let _ = writeln!(
1135 h,
1136 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1137 w.lands_as,
1138 esc(subject),
1139 w.lands_as_tail()
1140 );
1141
1142 let _ = writeln!(
1144 h,
1145 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1146 w.comments
1147 );
1148 if pr.review_comments.is_empty() {
1149 h.push_str(&format!(
1150 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1151 w.no_comments
1152 ));
1153 } else {
1154 for c in &pr.review_comments {
1155 let anchor = match (&c.path, c.line) {
1156 (Some(p), Some(l)) => format!("{p}:{l}"),
1157 (Some(p), None) => p.clone(),
1158 _ => "pull request thread".to_owned(),
1159 };
1160 let _ = writeln!(
1161 h,
1162 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1163 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1164 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1165 esc(&c.author),
1166 esc(&anchor),
1167 esc(&tail(&c.body, 800)),
1168 );
1169 }
1170 }
1171
1172 let total = diff.lines().count();
1174 let shown = total.min(DIFF_MAX_LINES);
1175 let _ = writeln!(
1176 h,
1177 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1178 w.diff
1179 );
1180 h.push_str(
1181 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1182 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1183 );
1184 for line in diff.lines().take(shown) {
1185 let (gutter, style, body) = diff_row(line);
1186 let _ = writeln!(
1187 h,
1188 "<div style=\"display:flex;{style}\">\
1189 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1190 border-right:1px solid #d0d7de\">{gutter}</span>\
1191 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1192 esc(body),
1193 );
1194 }
1195 h.push_str("</div>\n");
1196 if total > shown {
1197 let omitted = total - shown;
1198 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1199 let where_ = state.winner().map_or_else(
1200 || state.repo.display().to_string(),
1201 |w| w.worktree.display().to_string(),
1202 );
1203 let _ = writeln!(
1204 h,
1205 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1206 font-size:13px\">{}: {}</p>",
1207 w.truncated,
1208 w.truncated_note(
1209 omitted,
1210 total,
1211 shown,
1212 &esc(&where_),
1213 &esc(&state.base_branch),
1214 &esc(head),
1215 ),
1216 );
1217 }
1218
1219 h.push_str("</body>\n</html>\n");
1220 h
1221}
1222
1223fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1227 if state.config.graph.hold_contested_merge {
1228 state.contested_handoff.clone()
1229 } else {
1230 None
1231 }
1232}
1233
1234pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1243 let mut s = format!(
1244 "This is the merge approval for run {run} (`magi show {run}`). The question's \
1245 own text above names the pull request. Answering `{APPROVE}` squash-merges \
1246 it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1247 request open. Silence is a hold: the owner not answering never merges. Only \
1248 the owner choosing `{APPROVE}`, or writing the single word `{APPROVE}`, \
1249 merges; no other wording is a decision.\n\n\
1250 This brief is a snapshot from when you were attached: check `magi show {run}` \
1251 and `gh pr view` (read-only) before telling the owner anything current. \
1252 You run with permission to write the question record, and what keeps you \
1253 from touching anything else is this brief and your instructions - so do \
1254 not change files, branches or the pull request.",
1255 run = q.run
1256 );
1257 let Some(state) = state else {
1258 s.push_str(
1259 "\n\nThe run's record could not be read, so the pull request, the panel \
1260 summary and any contested findings are not known to you beyond the \
1261 question's own text. Say so to the owner rather than guessing.",
1262 );
1263 return s;
1264 };
1265 if let Some(pr) = &state.pr {
1266 s.push_str(&format!(
1267 "\n\nPull request #{} {} (recorded state: {}, last seen).",
1268 pr.number, pr.url, pr.state
1269 ));
1270 }
1271 s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1272 if let Some(w) = state.winner() {
1273 s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1274 }
1275 match contested_to_ask(state) {
1276 Some(c) => {
1277 s.push_str(
1278 "\n\nThis question was filed although merge approvals are off, because \
1279 the review hand-off is contested. Open findings:",
1280 );
1281 for f in &c.findings {
1282 let at = match (&f.file, f.line) {
1283 (Some(file), Some(line)) => format!(" ({file}:{line})"),
1284 (Some(file), None) => format!(" ({file})"),
1285 _ => String::new(),
1286 };
1287 s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1288 }
1289 let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1290 s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1291 }
1292 None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1293 }
1294 s
1295}
1296
1297async fn approval_gate(
1317 state: &mut RunState,
1318 pr: &PrState,
1319 subject: &str,
1320 contested: Option<&ContestedHandoff>,
1321 head: &str,
1322) -> Result<ApprovalGate> {
1323 let store = ask::Questions::open();
1324 let reusable = state
1329 .land_approval
1330 .as_ref()
1331 .filter(|a| a.head.eq_ignore_ascii_case(head))
1332 .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1333 if reusable.is_none() {
1334 for stale in store
1335 .list()
1336 .into_iter()
1337 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1338 {
1339 let why = "the pull request moved to a different head commit; asked again about it";
1340 if let Err(e) = store.update(&stale.id, |q| {
1341 q.abandon(why);
1342 Ok(())
1343 }) {
1344 tracing::warn!("could not retire the superseded approval question: {e:#}");
1345 }
1346 }
1347 }
1348
1349 let q = match reusable {
1350 Some(q) => q,
1351 None => {
1352 let worktree = match state.winner() {
1353 Some(w) => w.worktree.clone(),
1354 None => state.repo.clone(),
1355 };
1356 let head = if head.is_empty() {
1359 state
1360 .winner()
1361 .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1362 } else {
1363 head.to_owned()
1364 };
1365 let base = state.base_branch.clone();
1366 let range = format!("{base}...{head}");
1367 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1371 .await
1372 .map(|o| o.stdout)
1373 .unwrap_or_default();
1374 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1375 let commits: Vec<String> = git::git_raw(
1376 &worktree,
1377 &[
1378 "log",
1379 "--reverse",
1380 "--format=%s",
1381 &format!("{base}..{head}"),
1382 ],
1383 )
1384 .await
1385 .map(|o| o.stdout)
1386 .unwrap_or_default()
1387 .lines()
1388 .filter(|l| !l.trim().is_empty())
1389 .map(str::to_owned)
1390 .collect();
1391
1392 let w = words(&state.config.graph.language);
1393 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1394 let mut fresh = ask::Question::new(
1395 state.id.clone(),
1396 APPROVAL_NODE.to_owned(),
1397 "land".to_owned(),
1398 w.approval_summary(pr.number, subject),
1399 w.approval_detail(&pr.url, &base, subject, contested),
1400 vec![APPROVE.to_owned(), HOLD.to_owned()],
1401 );
1402 store
1403 .put_panel(&mut fresh, &html, &[])
1404 .context("write the merge approval panel")?;
1405 store
1406 .put(&mut fresh)
1407 .context("file the merge approval question")?;
1408 state.land_approval = Some(LandApproval {
1409 question: fresh.id.clone(),
1410 head: head.clone(),
1411 });
1412 state.event(
1413 "land",
1414 format!("asking for merge approval ({})", fresh.short()),
1415 );
1416 state.save()?;
1417 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1418 tracing::warn!(
1422 "could not notify about merge approval question {}: {e:#} - \
1423 the web UI is the only surface for it now",
1424 fresh.short()
1425 );
1426 }
1427 fresh
1428 }
1429 };
1430
1431 Ok(match q.status {
1432 ask::QuestionStatus::Open => ApprovalGate::Pending,
1433 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1437 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1441 Approval::Merge => ApprovalGate::Approved,
1442 Approval::Hold => ApprovalGate::Held,
1443 },
1444 })
1445}
1446
1447fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1450 let mut failing = Vec::new();
1451 let mut pending = false;
1452 let mut unknown = false;
1453 for check in rollup {
1454 match check.verdict() {
1455 Verdict::Pass => {}
1456 Verdict::Pending => pending = true,
1457 Verdict::Fail => failing.push(check.label()),
1458 Verdict::Unknown => unknown = true,
1459 }
1460 }
1461 let checks = if rollup.is_empty() {
1462 Checks::Unknown
1463 } else if pending {
1464 Checks::Pending
1465 } else if !failing.is_empty() {
1466 Checks::Red
1467 } else if unknown {
1468 Checks::Unknown
1469 } else {
1470 Checks::Green
1471 };
1472 (checks, failing)
1473}
1474
1475pub fn parse_pr(json: &str) -> Result<PrState> {
1478 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1479 let state = match raw.state.to_ascii_uppercase().as_str() {
1480 "OPEN" => PrLifecycle::Open,
1481 "MERGED" => PrLifecycle::Merged,
1482 "CLOSED" => PrLifecycle::Closed,
1483 other => bail!("unknown pull request state `{other}`"),
1484 };
1485
1486 let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1487
1488 let mut review_comments = Vec::new();
1489 for r in raw.reviews {
1490 push_if_outstanding(
1491 &mut review_comments,
1492 ReviewComment {
1493 author: r.author.login,
1494 path: None,
1495 line: None,
1496 body: r.body,
1497 },
1498 );
1499 }
1500 for c in raw.comments {
1501 push_if_outstanding(
1502 &mut review_comments,
1503 ReviewComment {
1504 author: c.author.login,
1505 path: None,
1506 line: None,
1507 body: c.body,
1508 },
1509 );
1510 }
1511
1512 Ok(PrState {
1513 url: raw.url,
1514 number: raw.number,
1515 state,
1516 checks,
1517 failing,
1518 review_comments,
1519 blocking: Blocking::of(&raw.merge_state_status),
1520 })
1521}
1522
1523pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1533 let view = gh(
1534 repo,
1535 &[
1536 "pr".to_owned(),
1537 "view".to_owned(),
1538 pr_url.to_owned(),
1539 "--json".to_owned(),
1540 "state".to_owned(),
1541 ],
1542 )
1543 .await?;
1544 if !view.0 {
1545 bail!("gh pr view {pr_url}: {}", view.1);
1546 }
1547 Ok(parse_pr(&view.1)?.state)
1551}
1552
1553#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1557pub struct ExternalMerge {
1558 pub url: String,
1560 pub number: u64,
1562}
1563
1564#[derive(Debug, Deserialize)]
1565#[serde(rename_all = "camelCase")]
1566struct GhMergedPr {
1567 url: String,
1568 number: u64,
1569 merged_at: String,
1570 base_ref_name: String,
1571}
1572
1573fn pick_merged_pr(
1587 json: &str,
1588 base_branch: &str,
1589 created_at: Timestamp,
1590) -> Result<Option<ExternalMerge>> {
1591 let raw: Vec<GhMergedPr> =
1592 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1593 let mut matches: Vec<ExternalMerge> = Vec::new();
1594 for pr in raw {
1595 if pr.base_ref_name != base_branch {
1596 continue;
1597 }
1598 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1599 continue;
1600 };
1601 if merged_at < created_at {
1602 continue;
1603 }
1604 matches.push(ExternalMerge {
1605 url: pr.url,
1606 number: pr.number,
1607 });
1608 }
1609 if matches.len() == 1 {
1610 Ok(matches.pop())
1611 } else {
1612 Ok(None)
1613 }
1614}
1615
1616#[derive(Debug, Clone, PartialEq, Eq)]
1618pub enum OpenPr {
1619 None,
1621 One {
1623 url: String,
1625 title: String,
1627 },
1628 Many(Vec<String>),
1630}
1631
1632#[derive(Debug, Deserialize)]
1633#[serde(rename_all = "camelCase")]
1634struct GhOpenPr {
1635 url: String,
1639 #[serde(default)]
1640 title: String,
1641 base_ref_name: String,
1642}
1643
1644pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1650 let raw: Vec<GhOpenPr> =
1651 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1652 let mut hits: Vec<GhOpenPr> = raw
1653 .into_iter()
1654 .filter(|p| p.base_ref_name == base)
1655 .collect();
1656 Ok(match hits.len() {
1657 0 => OpenPr::None,
1658 1 => {
1659 let p = hits.remove(0);
1660 OpenPr::One {
1661 url: p.url,
1662 title: p.title,
1663 }
1664 }
1665 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1666 })
1667}
1668
1669pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1673 let (ok, out) = gh(
1674 repo,
1675 &[
1676 "pr".to_owned(),
1677 "list".to_owned(),
1678 "--head".to_owned(),
1679 branch.to_owned(),
1680 "--base".to_owned(),
1681 base.to_owned(),
1682 "--state".to_owned(),
1683 "open".to_owned(),
1684 "--json".to_owned(),
1685 "number,url,title,baseRefName".to_owned(),
1686 ],
1687 )
1688 .await?;
1689 if !ok {
1690 bail!("gh pr list failed: {out}");
1691 }
1692 pick_open_pr(&out, base)
1693}
1694
1695#[derive(Debug, Deserialize)]
1696#[serde(rename_all = "camelCase")]
1697struct GhPrHead {
1698 head_ref_name: String,
1699 base_ref_name: String,
1700 state: String,
1701 is_cross_repository: bool,
1704 head_ref_oid: String,
1707}
1708
1709#[derive(Debug, Clone, PartialEq, Eq)]
1711pub struct Refusal {
1712 pub retry: bool,
1715 pub why: String,
1717}
1718
1719impl Refusal {
1720 fn final_(why: String) -> Self {
1721 Self { retry: false, why }
1722 }
1723}
1724
1725pub fn closable(
1734 json: &str,
1735 branch: &str,
1736 base: &str,
1737 verified: &[String],
1738) -> std::result::Result<(), Refusal> {
1739 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1740 retry: true,
1741 why: format!("could not read the pull request ({e})"),
1742 })?;
1743 if pr.head_ref_name != branch {
1744 return Err(Refusal::final_(format!(
1745 "its head is `{}`, not this run's `{branch}`",
1746 pr.head_ref_name
1747 )));
1748 }
1749 if pr.is_cross_repository {
1750 return Err(Refusal::final_("its head lives in a fork".to_owned()));
1751 }
1752 if pr.base_ref_name != base {
1753 return Err(Refusal::final_(format!(
1754 "it targets `{}`, not `{base}`",
1755 pr.base_ref_name
1756 )));
1757 }
1758 if !pr.state.eq_ignore_ascii_case("open") {
1759 return Err(Refusal::final_(format!(
1760 "it is already {}",
1761 pr.state.to_ascii_lowercase()
1762 )));
1763 }
1764 if !verified.contains(&pr.head_ref_oid) {
1768 return Err(Refusal {
1769 retry: true,
1770 why: format!(
1771 "its head {} is not a commit this run checked against the base",
1772 crate::already::short_sha(&pr.head_ref_oid)
1773 ),
1774 });
1775 }
1776 Ok(())
1777}
1778
1779async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
1784 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
1785 return false;
1786 };
1787 is_forge_url(url.trim())
1788}
1789
1790fn is_forge_url(url: &str) -> bool {
1791 url.contains("://") && !url.starts_with("file://")
1792 || url
1793 .split_once(':')
1794 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
1795}
1796
1797fn forge_unavailable(message: &str) -> bool {
1800 message.contains("known GitHub host") || message.contains("spawn gh")
1801}
1802
1803pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
1806 let how = match evidence.proof {
1807 crate::already::Proof::PatchId => format!(
1808 "carried by commit {} on `{base}` with the same patch",
1809 evidence.names()
1810 ),
1811 crate::already::Proof::Ancestry => {
1812 format!("already in the history of `{base}` as {}", evidence.names())
1813 }
1814 crate::already::Proof::Tree => format!(
1815 "already part of `{base}` (merging this branch changes nothing at {})",
1816 crate::already::short_sha(&evidence.tip)
1817 ),
1818 };
1819 format!(
1820 "Closing: everything this branch adds is {how}, so there is nothing left to \
1821 land. This pull request was closed automatically after that was verified; \
1822 reopen it if you disagree."
1823 )
1824}
1825
1826pub async fn close_superseded_pr(
1838 state: &mut RunState,
1839 branch: &str,
1840 evidence: &crate::already::Evidence,
1841 verified: &[String],
1842) -> Result<std::result::Result<String, String>> {
1843 let repo = state.repo.clone();
1844 let base = state.base_branch.clone();
1845 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
1846 Some(p) => p.url.clone(),
1847 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
1852 return Ok(Err(
1853 "the remote is not a forge, so there is no pull request".to_owned(),
1854 ));
1855 }
1856 None => match find_open_pr(&repo, branch, &base).await {
1857 Err(e) if forge_unavailable(&format!("{e:#}")) => {
1858 return Ok(Err(format!("no forge to ask: {e:#}")));
1859 }
1860 Err(e) => return Err(e),
1861 Ok(OpenPr::One { url, .. }) => url,
1862 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
1863 Ok(OpenPr::Many(urls)) => {
1864 return Ok(Err(format!(
1865 "{} open pull requests name it; not choosing between them",
1866 urls.len()
1867 )));
1868 }
1869 },
1870 };
1871 let (ok, view) = gh(
1872 &repo,
1873 &[
1874 "pr".to_owned(),
1875 "view".to_owned(),
1876 url.clone(),
1877 "--json".to_owned(),
1878 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
1879 ],
1880 )
1881 .await?;
1882 if !ok {
1883 bail!("gh pr view {url} failed: {view}");
1884 }
1885 if let Err(refusal) = closable(&view, branch, &base, verified) {
1886 if refusal.retry {
1889 bail!("left {url} open: {}", refusal.why);
1890 }
1891 return Ok(Err(format!("left {url} open: {}", refusal.why)));
1892 }
1893 let (ok, out) = gh(
1894 &repo,
1895 &[
1896 "pr".to_owned(),
1897 "close".to_owned(),
1898 url.clone(),
1899 "--comment".to_owned(),
1900 superseded_comment(&base, evidence),
1901 ],
1902 )
1903 .await?;
1904 if !ok {
1905 bail!("gh pr close {url} failed: {out}");
1906 }
1907 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
1908 p.state = "closed".to_owned();
1909 }
1910 Ok(Ok(url))
1911}
1912
1913pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
1917 let (ok, out) = gh(
1918 repo,
1919 &[
1920 "pr".to_owned(),
1921 "edit".to_owned(),
1922 url.to_owned(),
1923 "--title".to_owned(),
1924 title.to_owned(),
1925 ],
1926 )
1927 .await?;
1928 if !ok {
1929 bail!("gh pr edit failed: {out}");
1930 }
1931 Ok(())
1932}
1933
1934pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1947 let Some(winner) = state.winner() else {
1948 return Ok(None);
1949 };
1950 let branch = winner.branch.clone();
1951 let out = gh(
1952 &state.repo,
1953 &[
1954 "pr".to_owned(),
1955 "list".to_owned(),
1956 "--head".to_owned(),
1957 branch.clone(),
1958 "--state".to_owned(),
1959 "merged".to_owned(),
1960 "--json".to_owned(),
1961 "url,number,mergedAt,baseRefName".to_owned(),
1962 ],
1963 )
1964 .await?;
1965 if !out.0 {
1966 bail!("gh pr list --head {branch}: {}", out.1);
1967 }
1968 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1969}
1970
1971pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1983 let out = tokio::process::Command::new("git")
1984 .args(["merge-base", "--is-ancestor", branch, base_branch])
1985 .current_dir(repo)
1986 .quiet()
1987 .stdin(std::process::Stdio::null())
1988 .output()
1989 .await
1990 .context("spawn git merge-base --is-ancestor")?;
1991 Ok(out.status.success())
1992}
1993
1994fn forge_slug(url: &str) -> Option<(String, &str)> {
2002 let rest = url.rsplit("://").next()?;
2003 let (host, path) = rest.split_once('/')?;
2004 if host.is_empty() {
2005 return None;
2006 }
2007 Some((host.to_ascii_lowercase(), path))
2008}
2009
2010pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2018 let (host, path) = forge_slug(url)?;
2019 let mut segments = path.split('/');
2020 let owner = segments.next()?;
2021 let repo = segments.next()?;
2022 let kind = segments.next()?;
2023 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2024 return None;
2025 }
2026 Some(format!("{host}/{owner}/{repo}"))
2027}
2028
2029fn slug_of_repo_url(url: &str) -> Option<String> {
2033 let (host, path) = forge_slug(url)?;
2034 let mut segments = path.split('/');
2035 let owner = segments.next()?;
2036 let repo = segments.next()?;
2037 if owner.is_empty() || repo.is_empty() {
2038 return None;
2039 }
2040 Some(format!("{host}/{owner}/{repo}"))
2041}
2042
2043pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2064 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2065 return Ok(());
2066 }
2067 bail!(
2068 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2069 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2070 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2071 verify by hand before treating this as a false positive)"
2072 );
2073}
2074
2075async fn repo_slug(repo: &Path) -> Result<String> {
2086 let out = gh(
2087 repo,
2088 &[
2089 "repo".to_owned(),
2090 "view".to_owned(),
2091 "--json".to_owned(),
2092 "url".to_owned(),
2093 ],
2094 )
2095 .await?;
2096 if !out.0 {
2097 bail!("gh repo view --json url: {}", out.1);
2098 }
2099 #[derive(Debug, Deserialize)]
2100 struct GhRepo {
2101 url: String,
2102 }
2103 let parsed: GhRepo = serde_json::from_str(&out.1)
2104 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2105 slug_of_repo_url(&parsed.url)
2106 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2107}
2108
2109pub async fn correct_manual_merge(
2157 state: &mut RunState,
2158 url: &str,
2159) -> Result<(RunStatus, RunStatus)> {
2160 let Some(pr_slug) = slug_of_pr_url(url) else {
2161 bail!(
2162 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2163 this pull request belongs to"
2164 );
2165 };
2166 let run_slug = repo_slug(&state.repo).await?;
2167 ensure_same_repo(&run_slug, &pr_slug)?;
2168 correct_merge(state, url).await
2169}
2170
2171pub(crate) async fn correct_confirmed_external_merge(
2182 state: &mut RunState,
2183 url: &str,
2184) -> Result<(RunStatus, RunStatus)> {
2185 correct_merge(state, url).await
2186}
2187
2188fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2190 let Some(pr) = a.pr.as_ref() else {
2191 return false;
2192 };
2193 if !url.is_empty()
2194 && pr
2195 .url
2196 .trim_end_matches('/')
2197 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2198 {
2199 return true;
2200 }
2201 number > 0
2202 && pr.number == number
2203 && match (a.repo.canonicalize(), repo.canonicalize()) {
2204 (Ok(x), Ok(y)) => x == y,
2205 _ => a.repo == repo,
2206 }
2207}
2208
2209fn rewrite_open_prs(
2222 home: &Path,
2223 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2224) -> usize {
2225 let now = Timestamp::now();
2226 let mut changed = 0;
2227 for id in crate::run::list_ids_in(&home.join("runs")) {
2228 let path = home.join("runs").join(&id).join("run.json");
2229 let Ok(body) = std::fs::read_to_string(&path) else {
2230 continue;
2231 };
2232 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2233 continue;
2234 };
2235 if !state.status.done()
2236 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2237 || crate::daemon::is_working_on(home, &id, now)
2238 {
2239 continue;
2240 }
2241 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2242 continue;
2243 };
2244 if let Some(pr) = state.pr.as_mut() {
2245 pr.state = to.as_str().to_owned();
2246 }
2247 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2248 state.event(
2249 "land",
2250 format!("recorded {url} as {}: another run settled it", to.as_str()),
2251 );
2252 match state.save_under(home) {
2253 Ok(()) => changed += 1,
2254 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2255 }
2256 }
2257 changed
2258}
2259
2260pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2265 if to == PrLifecycle::Open {
2266 return;
2267 }
2268 let Some(home) = crate::run::try_home() else {
2269 return;
2270 };
2271 write_pr_state_through_in(&home, run, to);
2272}
2273
2274pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2275 let Some(pr) = run.pr.as_ref() else {
2276 return 0;
2277 };
2278 let (url, number) = (pr.url.clone(), pr.number);
2279 rewrite_open_prs(home, &mut |other| {
2280 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2281 })
2282}
2283
2284pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2287 let now = Timestamp::now();
2288 let mut out = Vec::new();
2289 for id in crate::run::list_ids_in(&home.join("runs")) {
2290 let path = home.join("runs").join(&id).join("run.json");
2291 let Ok(body) = std::fs::read_to_string(&path) else {
2292 continue;
2293 };
2294 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2295 continue;
2296 };
2297 if let Some(pr) = state.pr.as_ref()
2298 && state.status.done()
2299 && pr.state == "open"
2300 && !pr.url.is_empty()
2301 && !crate::daemon::is_working_on(home, &id, now)
2302 {
2303 out.push((id, state.repo.clone(), pr.url.clone()));
2304 }
2305 }
2306 out
2307}
2308
2309pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2312 rewrite_open_prs(home, &mut |s| {
2313 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2314 })
2315}
2316
2317pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2324 let mut known = BTreeMap::new();
2325 let mut failed = 0;
2326 let mut seen = BTreeSet::new();
2327 for (_, repo, url) in stale_open_prs(home) {
2328 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2329 continue;
2330 }
2331 match lifecycle(&repo, &url).await {
2332 Ok(state) => {
2333 known.insert(url, state);
2334 }
2335 Err(e) => {
2336 tracing::warn!("repair pr state of {url}: {e:#}");
2337 failed += 1;
2338 }
2339 }
2340 }
2341 (apply_pr_states(home, &known), failed)
2342}
2343
2344async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2345 match lifecycle(&state.repo, url).await? {
2346 PrLifecycle::Merged => {}
2347 other => bail!(
2348 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2349 other.as_str(),
2350 state.id
2351 ),
2352 }
2353 let before = state.status;
2354 if let Err(e) = land(state, url).await {
2355 state.status = RunStatus::Blocked;
2362 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2363 state.save()?;
2364 return Err(e).context(format!("confirming the merge of {url}"));
2365 }
2366 state.event(
2367 "fold",
2368 "operator recorded this pull request as a manual merge; this run never \
2369 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2370 bump this change might warrant has to be filed by hand",
2371 );
2372 if state.status == RunStatus::Merged {
2375 crate::followup::after_merge(state, url).await;
2376 }
2377 state.save()?;
2378 Ok((before, state.status))
2379}
2380
2381pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2388 let raw: Vec<GhInline> =
2389 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2390 let mut out = Vec::new();
2391 for c in raw {
2392 push_if_outstanding(
2393 &mut out,
2394 ReviewComment {
2395 author: c.user.login,
2396 path: c.path,
2397 line: c.line,
2398 body: c.body,
2399 },
2400 );
2401 }
2402 Ok(out)
2403}
2404
2405fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2411 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2412 return;
2413 }
2414 if comment.path.is_none() && is_noise(&comment.body) {
2415 return;
2416 }
2417 out.push(comment);
2418}
2419
2420pub fn is_noise(body: &str) -> bool {
2438 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2439 return true;
2440 }
2441 let mut content = false;
2442 for line in strip_blocks(body).lines() {
2443 let line = unquote(line);
2444 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2445 continue;
2446 }
2447 content = true;
2448 break;
2449 }
2450 !content
2451}
2452
2453fn strip_blocks(body: &str) -> String {
2455 let mut out = String::with_capacity(body.len());
2456 let mut rest = body;
2457 loop {
2458 let open = ["<!--", "<details>"]
2459 .iter()
2460 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2461 .min_by_key(|(i, _)| *i);
2462 let Some((at, tag)) = open else {
2463 out.push_str(rest);
2464 return out;
2465 };
2466 out.push_str(&rest[..at]);
2467 let after = &rest[at + tag.len()..];
2468 let close = if tag == "<!--" { "-->" } else { "</details>" };
2469 match after.find(close) {
2470 Some(end) => rest = &after[end + close.len()..],
2471 None => return out,
2473 }
2474 }
2475}
2476
2477fn unquote(line: &str) -> &str {
2479 let mut s = line.trim();
2480 while let Some(rest) = s.strip_prefix('>') {
2481 s = rest.trim_start();
2482 }
2483 s.trim()
2484}
2485
2486fn is_checklist(line: &str) -> bool {
2488 let rest = line
2489 .strip_prefix("- ")
2490 .or_else(|| line.strip_prefix("* "))
2491 .unwrap_or("");
2492 let rest = rest.trim_start();
2493 matches!(
2494 rest.get(..3),
2495 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2496 )
2497}
2498
2499fn is_decoration(line: &str) -> bool {
2501 line.starts_with('#')
2502 || line.starts_with("[!")
2503 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2504}
2505
2506fn is_banner(line: &str) -> bool {
2513 let plain = drop_spans(line, "**", "**");
2514 let plain = if plain.contains("](") {
2515 drop_spans(&plain, "[", ")")
2516 } else {
2517 plain
2518 };
2519 !plain.chars().any(char::is_alphanumeric)
2520}
2521
2522fn drop_spans(s: &str, open: &str, close: &str) -> String {
2526 let mut out = String::with_capacity(s.len());
2527 let mut rest = s;
2528 while let Some(at) = rest.find(open) {
2529 out.push_str(&rest[..at]);
2530 let after = &rest[at + open.len()..];
2531 match after.find(close) {
2532 Some(end) => rest = &after[end + close.len()..],
2533 None => return out,
2534 }
2535 }
2536 out.push_str(rest);
2537 out
2538}
2539
2540fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2558 static LOCKS: std::sync::LazyLock<
2559 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2560 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2561 LOCKS
2562 .lock()
2563 .unwrap_or_else(std::sync::PoisonError::into_inner)
2564 .entry(repo.to_path_buf())
2565 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2566 .clone()
2567}
2568
2569fn repo_label(repo: &Path, pr_url: &str) -> String {
2572 let parts: Vec<&str> = pr_url.split('/').collect();
2573 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2574 && at >= 2
2575 && !parts[at - 1].is_empty()
2576 && !parts[at - 2].is_empty()
2577 {
2578 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2579 }
2580 repo.file_name()
2581 .map(|n| n.to_string_lossy().into_owned())
2582 .unwrap_or_default()
2583}
2584
2585fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2589 (pr.checks == Checks::Red).then(|| {
2590 format!(
2591 "Merged {repo_name} PR #{} with red checks: {} ({})",
2592 pr.number,
2593 if pr.failing.is_empty() {
2594 "(none named)".to_owned()
2595 } else {
2596 pr.failing.join(", ")
2597 },
2598 pr.url
2599 )
2600 })
2601}
2602
2603async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2607 let repo_name = repo_label(&state.repo, &pr.url);
2608 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2609 return;
2610 };
2611 if let Some(rec) = state.pr.as_mut() {
2612 rec.red_at_merge = pr.failing.clone();
2613 }
2614 state.event("land", summary.clone());
2615 crate::notices::raise_with(
2618 crate::notices::merged_red(&state.id, &summary),
2619 &state.config.notify,
2620 );
2621}
2622
2623pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2630 land_with(state, pr_url, &GhForge).await
2631}
2632
2633trait Forge {
2637 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2638 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2639 async fn poll(&self);
2640 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2643 #[allow(clippy::too_many_arguments)]
2644 async fn fix(
2645 &self,
2646 state: &mut RunState,
2647 pr: &PrState,
2648 round: usize,
2649 budget: usize,
2650 reason: &str,
2651 logs: &str,
2652 ) -> Result<Fixed>;
2653}
2654
2655struct GhForge;
2656
2657impl Forge for GhForge {
2658 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2659 observe(repo, pr_url).await
2660 }
2661 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2662 gh(repo, argv).await
2663 }
2664 async fn poll(&self) {
2665 tokio::time::sleep(POLL).await;
2666 }
2667 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2668 required_contexts_of(repo, base).await
2669 }
2670 async fn fix(
2671 &self,
2672 state: &mut RunState,
2673 pr: &PrState,
2674 round: usize,
2675 budget: usize,
2676 reason: &str,
2677 logs: &str,
2678 ) -> Result<Fixed> {
2679 fix_round(state, pr, round, budget, reason, logs).await
2680 }
2681}
2682
2683fn encode_path_segment(s: &str) -> String {
2685 let mut out = String::new();
2686 for b in s.bytes() {
2687 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2688 out.push(b as char);
2689 } else {
2690 let _ = write!(out, "%{b:02X}");
2691 }
2692 }
2693 out
2694}
2695
2696async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2701 let enc = encode_path_segment(base);
2702 let mut all = BTreeSet::new();
2703 let classic = gh(
2706 repo,
2707 &[
2708 "api".to_owned(),
2709 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2710 ],
2711 )
2712 .await
2713 .ok()?;
2714 if classic.0 {
2715 all.extend(parse_classic_required(&classic.1)?);
2716 } else if !classic.1.contains("404") {
2717 return None;
2718 }
2719 let rules = gh(
2720 repo,
2721 &[
2722 "api".to_owned(),
2723 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2724 ],
2725 )
2726 .await
2727 .ok()?;
2728 if !rules.0 {
2729 return None;
2730 }
2731 all.extend(parse_ruleset_required(&rules.1)?);
2732 Some(all)
2733}
2734
2735fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2738 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2739 let mut out = BTreeSet::new();
2740 for c in v.get("contexts")?.as_array()? {
2741 out.insert(c.as_str()?.to_owned());
2742 }
2743 for c in v
2744 .get("checks")
2745 .and_then(|c| c.as_array())
2746 .into_iter()
2747 .flatten()
2748 {
2749 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
2750 out.insert(name.to_owned());
2751 }
2752 }
2753 Some(out)
2754}
2755
2756fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
2758 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2759 let mut out = BTreeSet::new();
2760 for rule in v.as_array()? {
2761 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
2762 continue;
2763 }
2764 let checks = rule
2765 .pointer("/parameters/required_status_checks")?
2766 .as_array()?;
2767 for c in checks {
2768 out.insert(c.get("context")?.as_str()?.to_owned());
2769 }
2770 }
2771 Some(out)
2772}
2773
2774fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
2782 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
2783}
2784
2785fn bound_head<'a>(
2795 seen_head: &'a str,
2796 rollup_head: &str,
2797 awaiting: Option<&str>,
2798) -> Option<&'a str> {
2799 if seen_head.is_empty()
2800 || awaiting_new_head(awaiting, seen_head)
2801 || !rollup_head.eq_ignore_ascii_case(seen_head)
2802 {
2803 return None;
2804 }
2805 Some(seen_head)
2806}
2807
2808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2810enum Refused {
2811 Pending,
2813 Recheck,
2817 Final,
2820}
2821
2822fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
2825 let Some(after) = after else {
2826 return Refused::Pending;
2828 };
2829 if after.pr.state != PrLifecycle::Open {
2830 return Refused::Final;
2831 }
2832 if !after.head.eq_ignore_ascii_case(observed_head) {
2835 return Refused::Pending;
2836 }
2837 if bound_head(&after.head, &after.rollup_head, None).is_none() {
2839 return Refused::Pending;
2840 }
2841 let state = after.merge_state.to_ascii_uppercase();
2842 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
2843 || state.is_empty()
2844 || state == "UNKNOWN"
2845 {
2846 return Refused::Pending;
2847 }
2848 if rechecked {
2849 Refused::Final
2850 } else {
2851 Refused::Recheck
2852 }
2853}
2854
2855async fn disarm<F: Forge>(
2860 forge: &F,
2861 state: &mut RunState,
2862 repo: &Path,
2863 number: u64,
2864) -> std::result::Result<(), String> {
2865 let argv = disable_automerge_argv(number);
2866 let out = {
2867 let merge_lock = repo_merge_lock(repo);
2868 let _merge_slot = merge_lock.lock().await;
2869 forge.merge(repo, &argv).await
2870 };
2871 match out {
2872 Ok((true, _)) => {
2873 state.land_armed_head = None;
2874 state.event("land", "auto-merge disabled");
2875 state.save().map_err(|e| format!("{e:#}"))?;
2876 Ok(())
2877 }
2878 Ok((false, msg)) => Err(msg),
2879 Err(e) => Err(format!("{e:#}")),
2880 }
2881}
2882
2883async fn stop_disarmed<F: Forge>(
2887 forge: &F,
2888 state: &mut RunState,
2889 repo: &Path,
2890 pr: &PrState,
2891 why: &str,
2892) -> Result<()> {
2893 if state.land_armed_head.is_none() {
2894 return stop(state, repo, pr, why).await;
2895 }
2896 match disarm(forge, state, repo, pr.number).await {
2897 Ok(()) => stop(state, repo, pr, why).await,
2898 Err(e) => {
2899 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
2900 stop(state, repo, pr, &why).await
2901 }
2902 }
2903}
2904
2905async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
2906 let repo = state.repo.clone();
2907 let budget = state.config.graph.land_rounds;
2908 let mut round = 0usize;
2909 let mut rebases = 0usize;
2912 let mut waited = Duration::ZERO;
2913 let mut shown: BTreeSet<String> = BTreeSet::new();
2918 let mut awaiting_head: Option<String> = None;
2922 let mut rechecked = false;
2924
2925 state.status = RunStatus::Landing;
2933 state.event("land", format!("watching {pr_url}"));
2934 state.save()?;
2935
2936 let mut resumed_armed = state.land_armed_head.is_some();
2940
2941 loop {
2942 let seen = forge.view(&repo, pr_url).await?;
2943 let mut pr = seen.pr.clone();
2944 pr.review_comments.retain(|c| !shown.contains(&c.body));
2945 state.pr = Some(crate::run::PrRecord {
2946 url: pr.url.clone(),
2947 number: pr.number,
2948 state: pr.state.as_str().to_owned(),
2949 checks: pr.checks.as_str().to_owned(),
2950 round,
2951 rounds: budget,
2952 red_at_merge: Vec::new(),
2953 });
2954 state.save()?;
2955
2956 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
2957 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
2962 let why = format!(
2963 "a previous pass may have armed auto-merge and it could not be disabled \
2964 on resume: {e}"
2965 );
2966 stop(state, &repo, &pr, &why).await?;
2967 return Ok(pr);
2968 }
2969 }
2970
2971 if pr.state == PrLifecycle::Open
2976 && !seen.head.is_empty()
2977 && state
2978 .land_armed_head
2979 .as_deref()
2980 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
2981 {
2982 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
2983 let why = format!(
2984 "the head moved while auto-merge was armed and it could not be disabled: {e}"
2985 );
2986 stop(state, &repo, &pr, &why).await?;
2987 return Ok(pr);
2988 }
2989 }
2990
2991 if pr.state == PrLifecycle::Open {
2992 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
2993 if waited >= WAIT_CEILING {
2994 let want = awaiting_head.as_deref().unwrap_or_default();
2995 let why = format!(
2996 "the pull request's checks were still not about one readable head after \
2997 {} minutes (expected {}, pull request points at {}, checks are for {}); \
2998 someone may have pushed over it",
2999 WAIT_CEILING.as_secs() / 60,
3000 if want.is_empty() { "any" } else { want },
3001 if seen.head.is_empty() {
3002 "nothing readable"
3003 } else {
3004 &seen.head
3005 },
3006 if seen.rollup_head.is_empty() {
3007 "nothing readable"
3008 } else {
3009 &seen.rollup_head
3010 },
3011 );
3012 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3013 return Ok(pr);
3014 }
3015 waited += POLL;
3016 forge.poll().await;
3017 continue;
3018 }
3019 if awaiting_head.take().is_some() {
3022 waited = Duration::ZERO;
3025 }
3026 }
3027
3028 let step = decide(&pr, round, budget, waited);
3029 let armed_here = state
3034 .land_armed_head
3035 .as_deref()
3036 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3037 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3038 if waited >= WAIT_CEILING {
3039 let required = if seen.base.is_empty() {
3040 None
3041 } else {
3042 forge.required_contexts(&repo, &seen.base).await
3043 };
3044 let why = format!(
3045 "auto-merge was armed on {} but the pull request did not merge within {} \
3046 minutes ({})",
3047 seen.head,
3048 WAIT_CEILING.as_secs() / 60,
3049 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3050 );
3051 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3052 return Ok(pr);
3053 }
3054 waited += POLL;
3055 forge.poll().await;
3056 continue;
3057 }
3058 if armed_here && !matches!(step, Step::Done { .. }) {
3059 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3062 let why = format!("auto-merge could not be disabled: {e}");
3063 stop(state, &repo, &pr, &why).await?;
3064 return Ok(pr);
3065 }
3066 }
3067 match step {
3068 Step::Wait => {
3069 if waited >= WAIT_CEILING {
3070 let why = format!(
3071 "checks were still running after {} minutes",
3072 WAIT_CEILING.as_secs() / 60
3073 );
3074 stop(state, &repo, &pr, &why).await?;
3075 return Ok(pr);
3076 }
3077 waited += POLL;
3078 forge.poll().await;
3079 }
3080 Step::Done { merged } => {
3081 if let Some(armed) = state.land_armed_head.take() {
3086 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3087 let msg = format!(
3088 "{} merged on {} but the owner approved {armed}; review what landed",
3089 pr.url, seen.head
3090 );
3091 tracing::warn!("{msg}");
3092 state.event("land", msg);
3093 crate::notices::raise_with(
3097 crate::notices::Notice::warn(
3098 &format!("merged-unapproved-head:{}", state.id),
3099 "A pull request merged on a commit the owner did not approve; \
3100 review what landed",
3101 )
3102 .link(crate::notices::Link::Run {
3103 id: state.id.clone(),
3104 }),
3105 &state.config.notify,
3106 );
3107 }
3108 }
3109 state.status = if merged {
3110 RunStatus::Merged
3111 } else {
3112 RunStatus::Ready
3113 };
3114 let detail = if merged {
3115 format!("{} was merged", pr.url)
3116 } else {
3117 format!("{} was closed without merging", pr.url)
3118 };
3119 state.merge = Some(MergeOutcome {
3120 mode: MergeMode::Pr,
3121 ok: merged,
3122 detail: detail.clone(),
3123 empty: false,
3124 });
3125 state.event("land", detail);
3126 state.save()?;
3127 write_pr_state_through(state, pr.state);
3128 return Ok(pr);
3129 }
3130 Step::Merge => {
3131 let subject = merge_subject(
3132 crate::graph::landing_title(state, &seen.title),
3133 &crate::graph::landing_subject_source(state),
3134 );
3135 let contested = contested_to_ask(state);
3142 if state.config.graph.land_approval || contested.is_some() {
3143 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3144 .await?
3145 {
3146 ApprovalGate::Approved => {}
3147 ApprovalGate::Held => {
3148 stop(
3149 state,
3150 &repo,
3151 &pr,
3152 "the owner did not approve the merge (held or unanswered)",
3153 )
3154 .await?;
3155 return Ok(pr);
3156 }
3157 ApprovalGate::Pending => {
3165 state.parked = true;
3166 state.event(
3167 "land",
3168 "parked awaiting merge approval - resumes once answered",
3169 );
3170 state.save()?;
3171 return Ok(pr);
3172 }
3173 }
3174 }
3175 let observed_head = seen.head.clone();
3186 {
3189 let fresh = forge.view(&repo, pr_url).await.ok();
3190 if !direct_merge_is_safe(
3191 fresh.as_ref(),
3192 &observed_head,
3193 &shown,
3194 round,
3195 budget,
3196 waited,
3197 ) {
3198 if waited >= WAIT_CEILING {
3199 let why = "the pull request did not settle on the approved head \
3200 before it could be merged";
3201 stop(state, &repo, &pr, why).await?;
3202 return Ok(pr);
3203 }
3204 state.event(
3205 "land",
3206 "the pull request changed before merging; looking again",
3207 );
3208 waited += POLL;
3209 forge.poll().await;
3210 continue;
3211 }
3212 }
3213 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3214 let out = {
3215 let merge_lock = repo_merge_lock(&repo);
3216 let _merge_slot = merge_lock.lock().await;
3217 forge.merge(&repo, &argv).await?
3218 };
3219 if out.0 {
3220 let confirmed = forge
3226 .view(&repo, pr_url)
3227 .await
3228 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3229 if !confirmed {
3230 if waited >= WAIT_CEILING {
3231 let why = "the merge request succeeded but the pull request \
3232 could not be confirmed merged after waiting";
3233 stop(state, &repo, &pr, why).await?;
3234 return Ok(pr);
3235 }
3236 state.event(
3237 "land",
3238 "merge accepted but the pull request is not confirmed merged yet; waiting",
3239 );
3240 state.save()?;
3241 waited += POLL;
3242 forge.poll().await;
3243 continue;
3244 }
3245 pr.state = PrLifecycle::Merged;
3246 state.status = RunStatus::Merged;
3247 state.merge = Some(MergeOutcome {
3248 mode: MergeMode::Pr,
3249 ok: true,
3250 detail: format!("gh {}", argv.join(" ")),
3251 empty: false,
3252 });
3253 if let Some(pr_record) = state.pr.as_mut() {
3258 pr_record.state = pr.state.as_str().to_owned();
3259 }
3260 state.event("land", format!("merged {} as `{subject}`", pr.url));
3261 announce_red_merge(state, &pr).await;
3262 state.save()?;
3263 write_pr_state_through(state, pr.state);
3264 return Ok(pr);
3265 }
3266 let after_seen = forge.view(&repo, pr_url).await.ok();
3267 let after = after_seen.as_ref().map(|s| s.pr.state);
3268 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3269 pr.state = PrLifecycle::Merged;
3270 state.status = RunStatus::Merged;
3271 state.merge = Some(outcome);
3272 if let Some(pr_record) = state.pr.as_mut() {
3273 pr_record.state = pr.state.as_str().to_owned();
3274 }
3275 state.event("land", format!("merged {} as `{subject}`", pr.url));
3276 announce_red_merge(state, &pr).await;
3277 state.save()?;
3278 write_pr_state_through(state, pr.state);
3279 return Ok(pr);
3280 }
3281 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3282 match verdict {
3283 Refused::Final => {
3284 let merge_state = after_seen
3285 .as_ref()
3286 .map(|s| s.merge_state.as_str())
3287 .filter(|m| !m.is_empty())
3288 .unwrap_or("unknown");
3289 let why = format!(
3293 "the merge was refused: {} (merge state: {merge_state})",
3294 out.1
3295 );
3296 stop(state, &repo, &pr, &why).await?;
3297 return Ok(pr);
3298 }
3299 verdict => {
3300 if waited >= WAIT_CEILING {
3305 let why = format!(
3306 "the merge was still refused after {} minutes: {}",
3307 WAIT_CEILING.as_secs() / 60,
3308 out.1
3309 );
3310 stop(state, &repo, &pr, &why).await?;
3311 return Ok(pr);
3312 }
3313 if verdict == Refused::Recheck {
3314 rechecked = true;
3315 }
3316 state.event(
3317 "land",
3318 "merge refused while the branch policy is not satisfied yet; waiting",
3319 );
3320 state.save()?;
3321 waited += POLL;
3322 forge.poll().await;
3323 }
3324 }
3325 }
3326 Step::Rebase => {
3327 if rebases >= budget {
3333 let why = format!(
3334 "the base moved under this branch {budget} time(s) and it still does \
3335 not merge; rebasing again would only race it"
3336 );
3337 stop(state, &repo, &pr, &why).await?;
3338 return Ok(pr);
3339 }
3340 rebases += 1;
3341 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3342 stop(
3343 state,
3344 &repo,
3345 &pr,
3346 "the pull request conflicts and this run has no winning branch to rebase",
3347 )
3348 .await?;
3349 return Ok(pr);
3350 };
3351 let base = state.base_branch.clone();
3352 state.event(
3353 "land",
3354 format!("{} no longer merges; rebasing onto {base}", pr.url),
3355 );
3356 state.save()?;
3357
3358 git::fetch(&repo, "origin", &base).await.ok();
3362 let scratch = state.dir().join("rebase");
3363 let onto = format!("origin/{base}");
3364 let rebased =
3365 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3366 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3367 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3368 Err(e) => Err(e),
3369 };
3370 match rebased {
3371 Ok(None) => {
3372 let pushed = {
3373 let merge_lock = repo_merge_lock(&repo);
3374 let _merge_slot = merge_lock.lock().await;
3375 git::push_rewritten(&repo, "origin", &branch).await?
3376 };
3377 if !pushed.ok() {
3378 let why = format!(
3379 "rebased {branch} but could not push it: {}",
3380 pushed.stderr.trim()
3381 );
3382 stop(state, &repo, &pr, &why).await?;
3383 return Ok(pr);
3384 }
3385 let head =
3388 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3389 Ok(head) => head,
3390 Err(e) => {
3391 let why = format!(
3392 "rebased and pushed {branch} but could not read the pushed \
3393 commit: {e:#}"
3394 );
3395 stop(state, &repo, &pr, &why).await?;
3396 return Ok(pr);
3397 }
3398 };
3399 crate::graph::refresh_reviewed_commits(state, &branch).await;
3400 awaiting_head = Some(head);
3401 rechecked = false;
3402 state.event("land", format!("rebased {branch} onto {base}"));
3403 state.save()?;
3404 waited = Duration::ZERO;
3407 tokio::time::sleep(POLL).await;
3408 }
3409 Ok(Some(conflict)) => {
3412 let why = format!(
3413 "{} conflicts with {base} and the rebase did not apply: {}",
3414 pr.url,
3415 conflict.chars().take(600).collect::<String>()
3416 );
3417 stop(state, &repo, &pr, &why).await?;
3418 return Ok(pr);
3419 }
3420 Err(e) => {
3421 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3422 stop(state, &repo, &pr, &why).await?;
3423 return Ok(pr);
3424 }
3425 }
3426 }
3427 Step::GiveUp { reason } => {
3428 stop(state, &repo, &pr, &reason).await?;
3429 return Ok(pr);
3430 }
3431 Step::Fix { reason } => {
3432 round += 1;
3433 waited = Duration::ZERO;
3434 for c in &pr.review_comments {
3435 shown.insert(c.body.clone());
3436 }
3437 state.event("land", format!("round {round}: {reason}"));
3438 state.save()?;
3439
3440 let logs = failing_logs(&repo, &seen.failing_urls).await;
3441 let was_red = pr.checks == Checks::Red;
3442 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3443 Fixed::Committed { head } => {
3444 awaiting_head = Some(head);
3447 rechecked = false;
3448 waited = Duration::ZERO;
3449 forge.poll().await;
3450 }
3451 Fixed::Declined if was_red => {
3452 let why = format!(
3453 "the fixer produced no commit while {} check(s) were failing \
3454 ({}); stopping instead of looping on an unchanged tree",
3455 pr.failing.len(),
3456 pr.failing.join(", ")
3457 );
3458 stop(state, &repo, &pr, &why).await?;
3459 return Ok(pr);
3460 }
3461 Fixed::Declined => state.event(
3466 "land",
3467 format!("round {round}: fixer declined the comments, nothing committed"),
3468 ),
3469 Fixed::Failed(why) => {
3470 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3471 return Ok(pr);
3472 }
3473 }
3474 state.save()?;
3475 }
3476 }
3477 }
3478}
3479
3480#[derive(Clone)]
3484struct Seen {
3485 pr: PrState,
3486 title: String,
3487 failing_urls: Vec<(String, String)>,
3488 head: String,
3490 rollup_head: String,
3493 merge_state: String,
3496 contexts: Vec<CheckInfo>,
3498 base: String,
3500}
3501
3502#[derive(Clone)]
3504struct CheckInfo {
3505 label: String,
3506 verdict: Verdict,
3507 required: Option<bool>,
3508}
3509
3510async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3514 let view = gh(
3515 repo,
3516 &[
3517 "pr".to_owned(),
3518 "view".to_owned(),
3519 pr_url.to_owned(),
3520 "--json".to_owned(),
3521 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3522 .to_owned(),
3523 ],
3524 )
3525 .await?;
3526 if !view.0 {
3527 bail!("gh pr view {pr_url}: {}", view.1);
3528 }
3529 let number = parse_pr(&view.1)?.number;
3530 let node = last_commit_node(repo, number).await;
3531 let mut seen = seen_from(&view.1, node.as_deref())?;
3532
3533 let inline = gh(
3534 repo,
3535 &[
3536 "api".to_owned(),
3537 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3538 ],
3539 )
3540 .await?;
3541 if inline.0 {
3542 match parse_inline_comments(&inline.1) {
3543 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3544 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3547 }
3548 } else {
3549 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3550 }
3551 Ok(seen)
3552}
3553
3554fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3565 let mut pr = parse_pr(view_json)?;
3566 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3567
3568 let mut rollup_head = String::new();
3569 let mut failing_urls = Vec::new();
3570 let mut contexts = Vec::new();
3571 let mut checks = Checks::Unknown;
3572 let mut failing = Vec::new();
3573 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3574 (checks, failing) = rollup_verdict(&rollup);
3575 failing_urls = rollup
3576 .iter()
3577 .filter(|c| c.verdict() == Verdict::Fail)
3578 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3579 .collect();
3580 contexts = rollup
3581 .iter()
3582 .map(|c| CheckInfo {
3583 label: c.label(),
3584 verdict: c.verdict(),
3585 required: c.is_required,
3586 })
3587 .collect();
3588 rollup_head = oid;
3589 }
3590 pr.checks = checks;
3591 pr.failing = failing;
3592
3593 Ok(Seen {
3594 pr,
3595 title: raw.title,
3596 failing_urls,
3597 head: raw.head_ref_oid,
3598 rollup_head,
3599 merge_state: raw.merge_state_status,
3600 contexts,
3601 base: raw.base_ref_name,
3602 })
3603}
3604
3605fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3608 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3609 if v.get("errors").is_some_and(|e| !e.is_null()) {
3610 return None;
3611 }
3612 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3613 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3614 let contexts = commit.pointer("/statusCheckRollup/contexts");
3615 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3616 return Some((oid.to_owned(), Vec::new()));
3618 };
3619 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3620 return None;
3621 }
3622 let nodes = contexts.get("nodes")?.as_array()?;
3623 let rollup = nodes
3624 .iter()
3625 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3626 .collect::<Result<Vec<_>, _>>()
3627 .ok()?;
3628 Some((oid.to_owned(), rollup))
3629}
3630
3631async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3634 let out = gh(
3635 repo,
3636 &[
3637 "api".to_owned(),
3638 "graphql".to_owned(),
3639 "-F".to_owned(),
3640 "owner={owner}".to_owned(),
3641 "-F".to_owned(),
3642 "repo={repo}".to_owned(),
3643 "-F".to_owned(),
3644 format!("number={number}"),
3645 "-f".to_owned(),
3646 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3647 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3648 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3649 ... on CheckRun{name status conclusion detailsUrl \
3650 isRequired(pullRequestNumber:$number)} \
3651 ... on StatusContext{context state targetUrl \
3652 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3653 .to_owned(),
3654 ],
3655 )
3656 .await
3657 .ok()?;
3658 out.0.then_some(out.1)
3659}
3660
3661#[doc(hidden)]
3663#[derive(Debug, PartialEq)]
3664pub enum Fixed {
3665 Committed {
3667 head: String,
3669 },
3670 Declined,
3672 Failed(String),
3674}
3675
3676#[doc(hidden)]
3682pub async fn fix_round(
3683 state: &mut RunState,
3684 pr: &PrState,
3685 round: usize,
3686 budget: usize,
3687 reason: &str,
3688 logs: &str,
3689) -> Result<Fixed> {
3690 let winner = state
3691 .winner()
3692 .cloned()
3693 .context("landing needs a winning candidate; none is recorded on this run")?;
3694 let roles = state
3695 .config
3696 .resolve_roles()
3697 .context("resolve the roster for the fix round")?;
3698 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
3702 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3703 _ => (
3704 state
3705 .config
3706 .agent(&winner.agent)
3707 .cloned()
3708 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
3709 format!("impl-{}", winner.label),
3710 ),
3711 };
3712
3713 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3714 let mut seat = seat_of(state, &seat_key, &spec.id);
3715 let artifacts = agent::artifacts_dir(&state.dir());
3716 let prompt = if state.config.cache_dir().is_some() {
3717 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3718 } else {
3719 prompt
3720 };
3721 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3725 let out = agent::invoke(
3726 &spec,
3727 &mut seat,
3728 &Invocation {
3729 cwd: &winner.worktree,
3730 prompt: &prompt,
3731 timeout: Duration::from_secs(state.config.graph.timeout_fix),
3732 allow_write: true,
3733 sessions: state.config.graph.sessions,
3734 artifacts: &artifacts,
3735 stem: &format!("land-{round}"),
3736 run: &state.id,
3737 node: "land",
3738 cache_dir: state.config.cache_dir().as_deref(),
3739 attachments: &[],
3740 writable: &[],
3741 },
3742 )
3743 .await;
3744 state.seats.insert(seat.key.clone(), seat);
3745
3746 match out {
3747 Ok(o) if o.quota_exhausted() => {
3748 return Ok(Fixed::Failed(
3749 "rate limited (quota); the fixer could not run".to_owned(),
3750 ));
3751 }
3752 Ok(o) if !o.usable() => {
3753 return Ok(Fixed::Failed(format!(
3754 "the fixer produced nothing usable (exit {:?}, timed out: {})",
3755 o.exit_code, o.timed_out
3756 )));
3757 }
3758 Ok(_) => {}
3759 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
3760 }
3761
3762 if let Ok(r) = git::rescue_commit(
3765 &winner.worktree,
3766 &format!("magi: land round {round} fixes (uncommitted work)"),
3767 )
3768 .await
3769 {
3770 state.note_withheld("land", &r.withheld);
3771 }
3772 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
3773 if after == before {
3774 return Ok(Fixed::Declined);
3775 }
3776
3777 let remote = state.config.merge.remote.clone();
3778 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
3779 if !push.ok() {
3780 return Ok(Fixed::Failed(format!(
3781 "pushing {} to {remote} failed: {}",
3782 winner.branch, push.stderr
3783 )));
3784 }
3785 state.event(
3786 "land",
3787 format!("round {round}: pushed a fix to {}", winner.branch),
3788 );
3789 Ok(Fixed::Committed { head: after })
3790}
3791
3792pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
3794 if let Some(existing) = state.seats.get(key)
3795 && existing.agent == agent
3796 {
3797 return existing.clone();
3798 }
3799 let fresh = SeatState::new(key, agent, state.seed);
3800 state.seats.insert(key.to_owned(), fresh.clone());
3801 fresh
3802}
3803
3804fn fix_prompt(
3806 state: &RunState,
3807 pr: &PrState,
3808 round: usize,
3809 budget: usize,
3810 reason: &str,
3811 logs: &str,
3812) -> String {
3813 let mut s = format!(
3814 "Your patch is open as a pull request and it is not landing. Land round \
3815 {round} of {budget}.\n\n\
3816 Pull request: {}\n\n\
3817 What is holding it: {reason}\n\n\
3818 # The task\n\n{}\n",
3819 pr.url, state.instruction
3820 );
3821
3822 if pr.failing.is_empty() {
3823 s.push_str("\n# Failing checks\n\n(none)\n");
3824 } else {
3825 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
3826 if logs.trim().is_empty() {
3827 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
3828 } else {
3829 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
3830 }
3831 }
3832
3833 if pr.review_comments.is_empty() {
3834 s.push_str("\n# Review comments\n\n(none)\n");
3835 } else {
3836 s.push_str("\n# Review comments\n");
3837 for c in &pr.review_comments {
3838 let where_ = match (&c.path, c.line) {
3839 (Some(p), Some(l)) => format!(" ({p}:{l})"),
3840 (Some(p), None) => format!(" ({p})"),
3841 _ => String::new(),
3842 };
3843 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
3844 }
3845 }
3846
3847 s.push_str(
3848 "\n# Rules\n\n\
3849 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
3850 failing test; do not silence a lint with an allow attribute; do not \
3851 stretch a timeout to hide a race. If the check is right, the code is \
3852 wrong.\n\
3853 2. Change nothing the checks and the comments did not raise. A \
3854 drive-by refactor turns a one-line fix into a pull request that \
3855 needs reviewing again.\n\
3856 3. If a comment is wrong, say so with a checkable argument and change \
3857 nothing for it. A declined comment with a reason is a correct \
3858 outcome; a change made to appease a reviewer is not.\n\
3859 4. Commit in this worktree. magi pushes to the pull request's branch \
3860 for you; do not push, merge, or close anything yourself.\n\
3861 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
3862 # Output\n\n\
3863 Say what you changed and why, and what you declined and why.",
3864 );
3865
3866 let language = &state.config.graph.language;
3867 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
3868 let _ = write!(s, "\n\nWrite all prose in {language}.");
3869 }
3870 s.push_str(&crate::prompt::github_english(language));
3872 if let Some(overlay) = state.config.prompts.overlay("fix") {
3873 let _ = write!(s, "\n\n{overlay}");
3874 }
3875 s
3876}
3877
3878async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
3881 let mut out = String::new();
3882 for (name, url) in failing.iter().take(MAX_LOGS) {
3883 let args = match (job_of(url), run_of(url)) {
3884 (Some(job), _) => vec![
3885 "run".to_owned(),
3886 "view".to_owned(),
3887 "--log-failed".to_owned(),
3888 "--job".to_owned(),
3889 job,
3890 ],
3891 (None, Some(run)) => vec![
3892 "run".to_owned(),
3893 "view".to_owned(),
3894 run,
3895 "--log-failed".to_owned(),
3896 ],
3897 (None, None) => continue,
3899 };
3900 let (ok, body) = match gh(repo, &args).await {
3901 Ok(v) => v,
3902 Err(e) => (false, format!("{e:#}")),
3903 };
3904 if !ok && body.trim().is_empty() {
3905 continue;
3906 }
3907 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
3908 }
3909 out
3910}
3911
3912fn job_of(details_url: &str) -> Option<String> {
3915 let after = details_url.split("/job/").nth(1)?;
3916 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3917 (!id.is_empty()).then_some(id)
3918}
3919
3920fn run_of(details_url: &str) -> Option<String> {
3922 let after = details_url.split("/actions/runs/").nth(1)?;
3923 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3924 (!id.is_empty()).then_some(id)
3925}
3926
3927fn stop_comment(run_id: &str, why: &str) -> String {
3931 format!(
3932 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
3933 The branch is untouched and the run is `{run_id}`. Nothing was merged."
3934 )
3935}
3936
3937async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
3942 let body = stop_comment(&state.id, why);
3943 let posted = gh(
3944 repo,
3945 &[
3946 "pr".to_owned(),
3947 "comment".to_owned(),
3948 pr.number.to_string(),
3949 "--body".to_owned(),
3950 body,
3951 ],
3952 )
3953 .await;
3954 match posted {
3955 Ok((true, _)) => {}
3956 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
3957 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
3958 }
3959 state.status = RunStatus::Blocked;
3960 state.merge = Some(MergeOutcome {
3961 mode: MergeMode::Pr,
3962 ok: false,
3963 detail: why.to_owned(),
3964 empty: false,
3965 });
3966 state.event("land", format!("stopped: {why}"));
3967 state.save()?;
3968 Ok(())
3969}
3970
3971async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
3987 let out = tokio::process::Command::new("gh")
3988 .args(args)
3989 .current_dir(cwd)
3990 .env_remove("GH_REPO")
3991 .quiet()
3992 .stdin(std::process::Stdio::null())
3993 .output()
3994 .await
3995 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
3996 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
3997 let err = String::from_utf8_lossy(&out.stderr);
3998 if body.trim().is_empty() {
3999 body = err.into_owned();
4000 } else if !err.trim().is_empty() {
4001 body.push_str(&err);
4002 }
4003 Ok((out.status.success(), body.trim().to_owned()))
4004}
4005
4006#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4008enum Verdict {
4009 Pass,
4010 Fail,
4011 Pending,
4012 Unknown,
4013}
4014
4015#[derive(Debug, Deserialize)]
4016#[serde(rename_all = "camelCase")]
4017struct GhPr {
4018 #[serde(default)]
4019 url: String,
4020 #[serde(default)]
4021 number: u64,
4022 #[serde(default)]
4023 state: String,
4024 #[serde(default)]
4025 title: String,
4026 #[serde(default)]
4027 status_check_rollup: Vec<GhCheck>,
4028 #[serde(default)]
4035 merge_state_status: String,
4036 #[serde(default)]
4040 head_ref_oid: String,
4041 #[serde(default)]
4042 base_ref_name: String,
4043 #[serde(default)]
4044 reviews: Vec<GhReview>,
4045 #[serde(default)]
4046 comments: Vec<GhComment>,
4047}
4048
4049#[derive(Debug, Deserialize)]
4054#[serde(rename_all = "camelCase")]
4055struct GhCheck {
4056 #[serde(default)]
4057 name: Option<String>,
4058 #[serde(default)]
4059 context: Option<String>,
4060 #[serde(default)]
4061 status: Option<String>,
4062 #[serde(default)]
4063 conclusion: Option<String>,
4064 #[serde(default)]
4065 state: Option<String>,
4066 #[serde(default)]
4067 details_url: Option<String>,
4068 #[serde(default)]
4069 target_url: Option<String>,
4070 #[serde(default)]
4073 is_required: Option<bool>,
4074}
4075
4076impl GhCheck {
4077 fn label(&self) -> String {
4079 self.name
4080 .clone()
4081 .or_else(|| self.context.clone())
4082 .unwrap_or_else(|| "(unnamed check)".to_owned())
4083 }
4084
4085 fn url(&self) -> Option<&str> {
4087 self.details_url
4088 .as_deref()
4089 .or(self.target_url.as_deref())
4090 .filter(|u| !u.is_empty())
4091 }
4092
4093 fn verdict(&self) -> Verdict {
4101 if let Some(status) = self.status.as_deref() {
4102 if !status.eq_ignore_ascii_case("COMPLETED") {
4103 return Verdict::Pending;
4104 }
4105 }
4106 let outcome = self
4107 .conclusion
4108 .as_deref()
4109 .or(self.state.as_deref())
4110 .unwrap_or("");
4111 match outcome.to_ascii_uppercase().as_str() {
4112 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4113 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4114 | "ACTION_REQUIRED" => Verdict::Fail,
4115 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4116 Verdict::Pending
4117 }
4118 _ => Verdict::Unknown,
4119 }
4120 }
4121}
4122
4123#[derive(Debug, Deserialize)]
4124struct GhAuthor {
4125 #[serde(default)]
4126 login: String,
4127}
4128
4129#[derive(Debug, Deserialize)]
4130struct GhReview {
4131 #[serde(default)]
4132 author: GhAuthor,
4133 #[serde(default)]
4134 body: String,
4135}
4136
4137#[derive(Debug, Deserialize)]
4138struct GhComment {
4139 #[serde(default)]
4140 author: GhAuthor,
4141 #[serde(default)]
4142 body: String,
4143}
4144
4145#[derive(Debug, Deserialize)]
4146struct GhUser {
4147 #[serde(default)]
4148 login: String,
4149}
4150
4151#[derive(Debug, Deserialize)]
4152struct GhInline {
4153 #[serde(default)]
4154 user: GhUser,
4155 #[serde(default)]
4156 path: Option<String>,
4157 #[serde(default)]
4158 line: Option<u64>,
4159 #[serde(default)]
4160 body: String,
4161}
4162
4163impl Default for GhAuthor {
4164 fn default() -> Self {
4165 Self {
4166 login: "(unknown)".to_owned(),
4167 }
4168 }
4169}
4170
4171impl Default for GhUser {
4172 fn default() -> Self {
4173 Self {
4174 login: "(unknown)".to_owned(),
4175 }
4176 }
4177}
4178
4179#[cfg(test)]
4180mod tests {
4181 use super::*;
4182 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4183
4184 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4185 format!(
4186 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4187 )
4188 }
4189
4190 #[test]
4191 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4192 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4193 assert_eq!(
4194 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4195 Ok(())
4196 );
4197 for (json, why) in [
4198 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4199 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4200 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4201 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4202 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4203 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4204 ] {
4205 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4206 .unwrap_err()
4207 .why;
4208 assert!(err.contains(why), "{json}: {err}");
4209 }
4210 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4212 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4213 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4214 assert!(
4215 !closable(
4216 &head_json("x", "main", "OPEN", false),
4217 "magi/27b2/A",
4218 "main",
4219 &[]
4220 )
4221 .unwrap_err()
4222 .retry
4223 );
4224 assert!(is_forge_url("https://github.com/o/r.git"));
4225 assert!(is_forge_url("git@github.com:o/r.git"));
4226 assert!(!is_forge_url("/tmp/origin.git"));
4227 assert!(!is_forge_url("C:\\work\\origin.git"));
4228 assert!(!is_forge_url("file:///tmp/origin.git"));
4229 assert!(forge_unavailable(
4230 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4231 ));
4232 assert!(!forge_unavailable(
4233 "gh pr list failed: error connecting to api.github.com"
4234 ));
4235 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4236 assert!(
4238 closable(
4239 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4240 "b",
4241 "main",
4242 &["aaa".to_owned()]
4243 )
4244 .is_err()
4245 );
4246 }
4247
4248 #[test]
4249 fn the_close_comment_names_the_commit_on_the_base() {
4250 let e = crate::already::Evidence {
4251 proof: crate::already::Proof::PatchId,
4252 tip: "1234567890".to_owned(),
4253 commits: vec!["0e368de0000".to_owned()],
4254 };
4255 let c = superseded_comment("main", &e);
4256 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4257 }
4258
4259 const GREEN_OPEN: &str = r####"{
4261 "url": "https://github.com/yukimemi/magi/pull/10",
4262 "number": 10,
4263 "state": "OPEN",
4264 "mergeStateStatus": "CLEAN",
4265 "statusCheckRollup": [
4266 {
4267 "__typename": "CheckRun",
4268 "conclusion": "SKIPPED",
4269 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4270 "name": "review",
4271 "status": "COMPLETED",
4272 "workflowName": "claude-review"
4273 },
4274 {
4275 "__typename": "CheckRun",
4276 "conclusion": "SUCCESS",
4277 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4278 "name": "check (ubuntu-latest)",
4279 "status": "COMPLETED",
4280 "workflowName": "CI"
4281 },
4282 {
4283 "__typename": "CheckRun",
4284 "conclusion": "SUCCESS",
4285 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4286 "name": "rustfmt",
4287 "status": "COMPLETED",
4288 "workflowName": "CI"
4289 },
4290 {
4291 "__typename": "StatusContext",
4292 "context": "CodeRabbit",
4293 "state": "SUCCESS",
4294 "targetUrl": ""
4295 }
4296 ],
4297 "reviews": [],
4298 "comments": [
4299 {
4300 "author": {
4301 "login": "coderabbitai"
4302 },
4303 "authorAssociation": "NONE",
4304 "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"
4305 }
4306 ]
4307}"####;
4308
4309 const RED_OPEN: &str = r####"{
4311 "url": "https://github.com/yukimemi/magi/pull/9",
4312 "number": 9,
4313 "state": "OPEN",
4314 "mergeStateStatus": "UNSTABLE",
4315 "statusCheckRollup": [
4316 {
4317 "__typename": "CheckRun",
4318 "conclusion": "SUCCESS",
4319 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4320 "name": "check (ubuntu-latest)",
4321 "status": "COMPLETED",
4322 "workflowName": "CI"
4323 },
4324 {
4325 "__typename": "CheckRun",
4326 "conclusion": "SUCCESS",
4327 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4328 "name": "rustfmt",
4329 "status": "COMPLETED",
4330 "workflowName": "CI"
4331 },
4332 {
4333 "__typename": "CheckRun",
4334 "conclusion": "FAILURE",
4335 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4336 "name": "editorconfig",
4337 "status": "COMPLETED",
4338 "workflowName": "CI"
4339 },
4340 {
4341 "__typename": "StatusContext",
4342 "context": "CodeRabbit",
4343 "state": "SUCCESS",
4344 "targetUrl": ""
4345 }
4346 ],
4347 "reviews": [],
4348 "comments": [
4349 {
4350 "author": {
4351 "login": "coderabbitai"
4352 },
4353 "authorAssociation": "NONE",
4354 "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"
4355 }
4356 ]
4357}"####;
4358
4359 const PENDING_OPEN: &str = r####"{
4361 "url": "https://github.com/yukimemi/magi/pull/9",
4362 "number": 9,
4363 "state": "OPEN",
4364 "statusCheckRollup": [
4365 {
4366 "__typename": "CheckRun",
4367 "conclusion": "SUCCESS",
4368 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4369 "name": "check (ubuntu-latest)",
4370 "status": "COMPLETED",
4371 "workflowName": "CI"
4372 },
4373 {
4374 "__typename": "CheckRun",
4375 "conclusion": "SUCCESS",
4376 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4377 "name": "rustfmt",
4378 "status": "COMPLETED",
4379 "workflowName": "CI"
4380 },
4381 {
4382 "__typename": "CheckRun",
4383 "conclusion": null,
4384 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4385 "name": "editorconfig",
4386 "status": "IN_PROGRESS",
4387 "workflowName": "CI"
4388 },
4389 {
4390 "__typename": "StatusContext",
4391 "context": "CodeRabbit",
4392 "state": "SUCCESS",
4393 "targetUrl": ""
4394 }
4395 ],
4396 "reviews": [],
4397 "comments": []
4398}"####;
4399
4400 const MERGED: &str = r####"{
4402 "url": "https://github.com/yukimemi/magi/pull/16",
4403 "number": 16,
4404 "state": "MERGED",
4405 "statusCheckRollup": [
4406 {
4407 "__typename": "CheckRun",
4408 "conclusion": "SUCCESS",
4409 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4410 "name": "check (ubuntu-latest)",
4411 "status": "COMPLETED",
4412 "workflowName": "CI"
4413 },
4414 {
4415 "__typename": "CheckRun",
4416 "conclusion": "SUCCESS",
4417 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4418 "name": "review",
4419 "status": "COMPLETED",
4420 "workflowName": "claude-review"
4421 }
4422 ],
4423 "reviews": [],
4424 "comments": []
4425}"####;
4426
4427 const REVIEWED_OPEN: &str = r####"{
4429 "url": "https://github.com/yukimemi/magi/pull/12",
4430 "number": 12,
4431 "state": "OPEN",
4432 "statusCheckRollup": [
4433 {
4434 "__typename": "CheckRun",
4435 "conclusion": "SUCCESS",
4436 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4437 "name": "check (ubuntu-latest)",
4438 "status": "COMPLETED",
4439 "workflowName": "CI"
4440 },
4441 {
4442 "__typename": "CheckRun",
4443 "conclusion": "SUCCESS",
4444 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4445 "name": "review",
4446 "status": "COMPLETED",
4447 "workflowName": "claude-review"
4448 }
4449 ],
4450 "reviews": [
4451 {
4452 "author": {
4453 "login": "claude"
4454 },
4455 "state": "COMMENTED",
4456 "body": ""
4457 }
4458 ],
4459 "comments": [
4460 {
4461 "author": {
4462 "login": "coderabbitai"
4463 },
4464 "authorAssociation": "NONE",
4465 "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"
4466 },
4467 {
4468 "author": {
4469 "login": "claude"
4470 },
4471 "authorAssociation": "NONE",
4472 "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"
4473 }
4474 ]
4475}"####;
4476
4477 const INLINE: &str = r####"[
4479 {
4480 "user": {
4481 "login": "claude[bot]"
4482 },
4483 "path": "src/graph.rs",
4484 "line": 231,
4485 "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"
4486 }
4487]"####;
4488
4489 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4491<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4492
4493> [!IMPORTANT]
4494> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4495>
4496> This repository does not receive automatic reviews because it has fewer than 10 stars.
4497>
4498> <details>
4499> <summary>⚙️ Run configuration</summary>
4500>
4501> **Configuration used**: defaults
4502>
4503> **Review profile**: CHILL
4504>
4505> **Plan**: Team
4506>
4507> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4508>
4509> </details>
4510
4511<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4512
4513<!-- tips_start -->
4514
4515---
4516
4517Thanks 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.
4518
4519<details>
4520<summary>❤️ Share</summary>
4521
4522- [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"####;
4523
4524 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4526
4527---
4528### Reviewing PR #16
4529
4530- [x] Read AGENTS.md conventions
4531- [x] Review `src/daemon.rs` changes
4532- [x] Review `src/main.rs` changes (new `doctor` reporting)
4533- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4534- [x] Check test coverage for new behavior
4535- [x] Run verification commands (blocked — see note)
4536- [x] Post findings"####;
4537
4538 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4540
4541---
4542### Review: `magi review <branch>` — cheap-half-only graph
4543
4544Read 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.
4545
4546**Correctness**
4547
4548- 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"####;
4549
4550 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4551 PrState {
4552 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4553 number: 16,
4554 state: PrLifecycle::Open,
4555 checks,
4556 blocking: if matches!(checks, Checks::Red) {
4560 Blocking::Yes
4561 } else {
4562 Blocking::No
4563 },
4564 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4565 review_comments: (0..comments)
4566 .map(|i| ReviewComment {
4567 author: "coderabbitai".to_owned(),
4568 path: Some("src/graph.rs".to_owned()),
4569 line: Some(231),
4570 body: format!("finding {i}"),
4571 })
4572 .collect(),
4573 }
4574 }
4575
4576 #[test]
4577 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4578 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4579 assert_eq!(state.number, 10);
4580 assert_eq!(state.state, PrLifecycle::Open);
4581 assert_eq!(state.checks, Checks::Green);
4582 assert!(state.failing.is_empty());
4583 assert!(
4584 state.review_comments.is_empty(),
4585 "the only comment is CodeRabbit's trigger notice: {:?}",
4586 state.review_comments
4587 );
4588 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4589 }
4590
4591 #[test]
4592 fn a_failing_check_parses_as_red_and_is_named() {
4593 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4594 assert_eq!(state.checks, Checks::Red);
4595 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4596 let mut blocking = state.clone();
4603 blocking.blocking = Blocking::Yes;
4604 match decide(&blocking, 0, 4, Duration::ZERO) {
4605 Step::Fix { reason } => {
4606 assert!(reason.contains("editorconfig"), "reason: {reason}");
4607 assert!(reason.contains("failing"), "reason: {reason}");
4608 }
4609 other => panic!("expected a fix round, got {other:?}"),
4610 }
4611 }
4612
4613 #[test]
4614 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4615 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4616 assert_eq!(state.checks, Checks::Pending);
4617 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4618 }
4619
4620 #[test]
4621 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4622 let state = parse_pr(MERGED).expect("merged fixture parses");
4623 assert_eq!(state.state, PrLifecycle::Merged);
4624 assert_eq!(
4625 decide(&state, 0, 4, Duration::ZERO),
4626 Step::Done { merged: true }
4627 );
4628 }
4629
4630 #[test]
4631 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4632 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4633 assert_eq!(state.checks, Checks::Green);
4634 let authors: Vec<&str> = state
4635 .review_comments
4636 .iter()
4637 .map(|c| c.author.as_str())
4638 .collect();
4639 assert_eq!(
4640 authors,
4641 vec!["claude"],
4642 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4643 );
4644 match decide(&state, 0, 4, Duration::ZERO) {
4645 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4646 other => panic!("expected a fix round, got {other:?}"),
4647 }
4648 }
4649
4650 #[test]
4651 fn inline_review_comments_keep_their_file_and_line() {
4652 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4653 assert_eq!(comments.len(), 1);
4654 assert_eq!(comments[0].author, "claude[bot]");
4655 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4656 assert_eq!(comments[0].line, Some(231));
4657 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4658 }
4659
4660 #[test]
4661 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4662 assert!(
4663 is_noise(CODERABBIT_TRIGGER),
4664 "CodeRabbit's trigger notice declares itself not a review"
4665 );
4666 assert!(
4667 is_noise(CLAUDE_CHECKLIST),
4668 "a progress checklist asks for nothing"
4669 );
4670 assert!(
4671 !is_noise(CLAUDE_FINDING),
4672 "a review that names a bug is input, not noise"
4673 );
4674
4675 let mut clean = pr(Checks::Green, &[], 0);
4676 clean.review_comments.push(ReviewComment {
4677 author: "coderabbitai".to_owned(),
4678 path: None,
4679 line: None,
4680 body: CODERABBIT_TRIGGER.to_owned(),
4681 });
4682 clean.review_comments.retain(|c| !is_noise(&c.body));
4683 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4684
4685 let mut found = pr(Checks::Green, &[], 0);
4686 found.review_comments.push(ReviewComment {
4687 author: "claude".to_owned(),
4688 path: None,
4689 line: None,
4690 body: CLAUDE_FINDING.to_owned(),
4691 });
4692 found.review_comments.retain(|c| !is_noise(&c.body));
4693 assert!(matches!(
4694 decide(&found, 0, 4, Duration::ZERO),
4695 Step::Fix { .. }
4696 ));
4697 }
4698
4699 #[test]
4700 fn the_policy_table_holds_for_every_combination_that_matters() {
4701 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4702 (
4703 "pending checks are waited for, even on the last round",
4704 pr(Checks::Pending, &[], 0),
4705 4,
4706 4,
4707 Duration::ZERO,
4708 Step::Wait,
4709 ),
4710 (
4711 "red checks are fixed",
4712 pr(Checks::Red, &["editorconfig"], 0),
4713 0,
4714 4,
4715 Duration::ZERO,
4716 Step::Fix {
4717 reason: "1 check(s) failing: editorconfig".to_owned(),
4718 },
4719 ),
4720 (
4721 "green with comments is fixed, not merged",
4722 pr(Checks::Green, &[], 2),
4723 1,
4724 4,
4725 Duration::ZERO,
4726 Step::Fix {
4727 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4728 .to_owned(),
4729 },
4730 ),
4731 (
4732 "green and clean merges",
4733 pr(Checks::Green, &[], 0),
4734 3,
4735 4,
4736 Duration::ZERO,
4737 Step::Merge,
4738 ),
4739 (
4740 "an unreadable rollup is waited on while the grace lasts",
4741 pr(Checks::Unknown, &[], 0),
4742 0,
4743 4,
4744 Duration::ZERO,
4745 Step::Wait,
4746 ),
4747 (
4748 "an unreadable rollup is never merged once the grace is spent",
4749 pr(Checks::Unknown, &[], 0),
4750 0,
4751 4,
4752 CHECKS_GRACE,
4753 Step::GiveUp {
4754 reason: "no check status is readable on the pull request after 3 minute(s); \
4755 refusing to merge on a guess"
4756 .to_owned(),
4757 },
4758 ),
4759 ];
4760 for (what, state, round, budget, waited, want) in cases {
4761 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
4762 }
4763 }
4764
4765 #[test]
4766 fn the_forge_verdict_survives_the_round_trip_from_gh() {
4767 let green = parse_pr(GREEN_OPEN).expect("parse");
4771 assert_eq!(green.blocking, Blocking::No);
4772 let red = parse_pr(RED_OPEN).expect("parse");
4773 assert_eq!(
4774 red.blocking,
4775 Blocking::No,
4776 "`UNSTABLE` is mergeable: the red check is one nobody requires"
4777 );
4778 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
4779 let quiet =
4781 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
4782 assert_eq!(quiet.blocking, Blocking::Unsaid);
4783 }
4784
4785 #[test]
4786 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
4787 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
4793 nonblocking.blocking = Blocking::No;
4794 assert_eq!(
4795 decide(&nonblocking, 0, 4, Duration::ZERO),
4796 Step::Merge,
4797 "the forge says nothing is in the way, so nothing is"
4798 );
4799
4800 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
4802 blocking.blocking = Blocking::Yes;
4803 assert!(matches!(
4804 decide(&blocking, 0, 4, Duration::ZERO),
4805 Step::Fix { .. }
4806 ));
4807
4808 let mut commented = pr(Checks::Red, &["coverage"], 1);
4811 commented.blocking = Blocking::No;
4812 assert!(matches!(
4813 decide(&commented, 0, 4, Duration::ZERO),
4814 Step::Fix { .. }
4815 ));
4816
4817 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
4819 unsaid.blocking = Blocking::Unsaid;
4820 assert!(matches!(
4821 decide(&unsaid, 0, 4, Duration::ZERO),
4822 Step::Fix { .. }
4823 ));
4824 }
4825
4826 #[test]
4827 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
4828 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
4829 red.blocking = Blocking::No;
4830 assert_eq!(
4831 decide(&red, 0, 4, Duration::ZERO),
4832 Step::Merge,
4833 "announcing must not change the decision"
4834 );
4835 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
4836 assert!(said.contains("yukimemi/magi"), "{said}");
4837 assert!(said.contains("#16"), "{said}");
4838 assert!(
4839 said.contains("https://github.com/yukimemi/magi/pull/16"),
4840 "{said}"
4841 );
4842 assert!(
4843 said.contains("test (windows-latest)") && said.contains("coverage"),
4844 "{said}"
4845 );
4846
4847 let green = pr(Checks::Green, &["stale"], 0);
4849 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
4850 }
4851
4852 #[test]
4853 fn the_repo_label_comes_from_the_pull_request_url() {
4854 let p = Path::new("/tmp/checkout");
4855 assert_eq!(
4856 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
4857 "yukimemi/magi"
4858 );
4859 assert_eq!(repo_label(p, "not a url"), "checkout");
4860 }
4861
4862 #[test]
4863 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
4864 let mut conflicted = pr(Checks::Green, &[], 0);
4869 conflicted.blocking = Blocking::Conflict;
4870 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
4871
4872 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
4876 red.blocking = Blocking::Conflict;
4877 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
4878
4879 let mut merged = pr(Checks::Red, &[], 0);
4881 merged.blocking = Blocking::Conflict;
4882 merged.state = PrLifecycle::Merged;
4883 assert_eq!(
4884 decide(&merged, 0, 4, Duration::ZERO),
4885 Step::Done { merged: true }
4886 );
4887 }
4888
4889 #[test]
4890 fn the_forge_verdict_is_read_off_merge_state_status() {
4891 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
4894 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
4895 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
4896 }
4897 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
4898 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
4899 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
4900 for quiet in ["", "UNKNOWN"] {
4903 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
4904 assert!(Blocking::of(quiet).stops_a_merge());
4905 }
4906 }
4907
4908 #[test]
4909 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
4910 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
4911 let jj = "could not determine current branch: failed to run git: not on any branch";
4913
4914 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
4915 .expect("the forge says merged, so it merged");
4916 assert!(landed.ok);
4917 assert!(
4918 landed.detail.contains("but the pull request is merged"),
4919 "the record must not read as a clean success: {}",
4920 landed.detail
4921 );
4922 assert!(
4923 landed.detail.contains("not on any branch"),
4924 "and it must keep what the command actually said: {}",
4925 landed.detail
4926 );
4927
4928 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
4930 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
4931 assert!(merged_after_all(&argv, jj, None).is_none());
4933 }
4934
4935 #[test]
4936 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
4937 let mut state = pr(Checks::Red, &["editorconfig"], 3);
4938 state.state = PrLifecycle::Closed;
4939 assert_eq!(
4940 decide(&state, 0, 4, Duration::ZERO),
4941 Step::Done { merged: false },
4942 "a human closing the pull request ends the loop, whatever CI says"
4943 );
4944 }
4945
4946 #[test]
4947 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
4948 let red = decide(
4949 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
4950 4,
4951 4,
4952 Duration::ZERO,
4953 );
4954 match red {
4955 Step::GiveUp { reason } => {
4956 assert!(reason.contains("editorconfig"), "reason: {reason}");
4957 assert!(reason.contains("test (macos)"), "reason: {reason}");
4958 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
4959 }
4960 other => panic!("expected a give-up, got {other:?}"),
4961 }
4962
4963 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
4964 match commented {
4965 Step::GiveUp { reason } => {
4966 assert!(reason.contains("unresolved"), "reason: {reason}");
4967 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
4968 }
4969 other => panic!("expected a give-up, got {other:?}"),
4970 }
4971 }
4972
4973 #[test]
4974 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
4975 let candidate_commit = "magi: candidate A (uncommitted work)";
4976 let subject = merge_subject(candidate_commit, "add retries to the uploader");
4977 let argv = merge_argv(16, &subject);
4978
4979 assert!(argv.contains(&"--squash".to_owned()));
4980 assert!(argv.contains(&"--delete-branch".to_owned()));
4981 assert!(argv.contains(&"--subject".to_owned()));
4982 assert_eq!(
4983 argv.last().map(String::as_str),
4984 Some("add retries to the uploader"),
4985 "the subject must not be the candidate commit message"
4986 );
4987 assert_ne!(subject, candidate_commit);
4988 }
4989
4990 #[test]
4991 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
4992 assert_eq!(
4993 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
4994 "feat: a queue, an unattended loop, and a phone UI"
4995 );
4996 assert_eq!(
4997 merge_subject("", "# port the retry logic\n\ndetails"),
4998 "port the retry logic",
4999 "an empty title falls back to the task's first line, heading marks stripped"
5000 );
5001 }
5002
5003 #[test]
5004 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5005 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5006 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5007 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5008 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5009 assert_eq!(run_of(""), None);
5010 }
5011
5012 #[test]
5013 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5014 let mut out = Vec::new();
5015 push_if_outstanding(
5016 &mut out,
5017 ReviewComment {
5018 author: "yukimemi".to_owned(),
5019 path: None,
5020 line: None,
5021 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5022 },
5023 );
5024 assert!(out.is_empty());
5025 }
5026
5027 fn run_state() -> RunState {
5031 let mut state = RunState::new(
5032 std::path::PathBuf::from("/repo/magi"),
5033 "main".to_owned(),
5034 "abcdef1234".to_owned(),
5035 "add retries to the uploader".to_owned(),
5036 crate::config::Config::default(),
5037 );
5038 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5043 let nanos = std::time::SystemTime::now()
5044 .duration_since(std::time::UNIX_EPOCH)
5045 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5046 state.id = format!(
5047 "20261004-{nanos:06}-{:04x}",
5048 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5049 );
5050 state
5051 }
5052
5053 fn green_pr() -> PrState {
5054 PrState {
5055 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5056 number: 42,
5057 state: PrLifecycle::Open,
5058 checks: Checks::Green,
5059 blocking: Blocking::No,
5061 failing: Vec::new(),
5062 review_comments: vec![ReviewComment {
5063 author: "coderabbitai".to_owned(),
5064 path: Some("src/land.rs".to_owned()),
5065 line: Some(212),
5066 body: "this branch never checks the exit code".to_owned(),
5067 }],
5068 }
5069 }
5070
5071 #[test]
5072 fn github_facing_land_text_is_english_whatever_the_language() {
5073 let mut state = run_state();
5074 state.config.graph.language = "ja".to_owned();
5075 let comment = stop_comment(&state.id, "checks are still red");
5076 assert!(comment.is_ascii(), "{comment}");
5077 assert!(comment.starts_with(MARKER));
5078
5079 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5080 let ja_at = p.find("Write all prose in ja").unwrap();
5081 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5082 assert!(ja_at < rule_at, "{p}");
5083 assert!(p.contains("stays in Japanese"), "{p}");
5084
5085 state.config.graph.language = "en".to_owned();
5086 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5087 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5088 assert!(!p.contains("does not apply"), "{p}");
5089 }
5090
5091 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5092
5093 fn panel() -> String {
5094 approval_panel(
5095 &run_state(),
5096 &green_pr(),
5097 NUMSTAT,
5098 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5099 &[
5100 "land: ask before merging".to_owned(),
5101 "land: colour the diff".to_owned(),
5102 ],
5103 "feat: merge approval from the phone",
5104 )
5105 }
5106
5107 #[test]
5108 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5109 let html = panel();
5110 for needle in [
5111 "42",
5112 "main",
5113 "src/land.rs",
5114 "src/web.rs",
5115 "assets/logo.png",
5116 "feat: merge approval from the phone",
5117 "land: ask before merging",
5118 "land: colour the diff",
5119 "coderabbitai",
5120 "this branch never checks the exit code",
5121 "green",
5122 ] {
5123 assert!(html.contains(needle), "the panel must state `{needle}`");
5124 }
5125 }
5126
5127 fn winning_candidate(summary: &str) -> Candidate {
5130 Candidate {
5131 index: 0,
5132 label: 'A',
5133 agent: "opus".to_owned(),
5134 branch: "magi/x/A".to_owned(),
5135 worktree: PathBuf::from("/wt/A"),
5136 summary: summary.to_owned(),
5137 stat: String::new(),
5138 files: 1,
5139 commits: 1,
5140 empty: false,
5141 failed: None,
5142 verified_noop: None,
5143 duration_ms: 0,
5144 folded: false,
5145 }
5146 }
5147
5148 fn uncontested_tally() -> Tally {
5149 Tally {
5150 first_choice: BTreeMap::from([('A', 1)]),
5151 borda: BTreeMap::new(),
5152 winner: 'A',
5153 rankings: 1,
5154 unanimous_initial: true,
5155 deliberated: false,
5156 changed_votes: 0,
5157 unanimous_final: true,
5158 tie_break: None,
5159 judges: 1,
5160 present: 1,
5161 quorum: 1,
5162 met_quorum: true,
5163 uncontested: None,
5164 }
5165 }
5166
5167 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5168 ReviewRecord {
5169 attempts: 0,
5170 reviewer,
5171 agent: agent.to_owned(),
5172 summary: summary.to_owned(),
5173 findings: Vec::new(),
5174 vote: None,
5175 failed: None,
5176 duration_ms: 0,
5177 }
5178 }
5179
5180 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5181 let answered = reviews.len();
5182 ReviewRound {
5183 round,
5184 head: "abc1234".to_owned(),
5185 verified_head: None,
5186 verified_at: None,
5187 reviews,
5188 e2e: Vec::new(),
5189 verify_retried: false,
5190 e2e_deferred: false,
5191 e2e_defer_reason: None,
5192 fix: None,
5193 blocking: 0,
5194 answered,
5195 expected: answered,
5196 clean: true,
5197 progressed: false,
5198 vote_split: false,
5199 reconsideration: Vec::new(),
5200 verdict: None,
5201 }
5202 }
5203
5204 #[test]
5205 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5206 let en = panel();
5207 assert!(en.contains("Task"), "{en}");
5208 assert!(en.contains("add retries to the uploader"), "{en}");
5209
5210 let mut state = run_state();
5211 state.config.graph.language = "ja".to_owned();
5212 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5213 assert!(ja.contains("タスク"), "{ja}");
5214 assert!(
5215 ja.contains("add retries to the uploader"),
5216 "the task itself is not translated: {ja}"
5217 );
5218 }
5219
5220 #[test]
5221 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5222 let html = panel();
5226 assert!(!html.contains("What changed"), "{html}");
5227 assert!(!html.contains("Review verdict"), "{html}");
5228 }
5229
5230 #[test]
5231 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5232 let mut state = run_state();
5233 state.candidates = vec![winning_candidate("")];
5234 state.tally = Some(uncontested_tally());
5235 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5236 assert!(
5237 !html.contains("What changed"),
5238 "an empty summary must not render an empty box: {html}"
5239 );
5240 }
5241
5242 #[test]
5243 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5244 let mut state = run_state();
5245 state.candidates = vec![winning_candidate(
5246 "Added a retry loop around the uploader PUT call.",
5247 )];
5248 state.tally = Some(uncontested_tally());
5249 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5250 assert!(en.contains("What changed"), "{en}");
5251 assert!(
5252 en.contains("Added a retry loop around the uploader PUT call."),
5253 "{en}"
5254 );
5255
5256 state.config.graph.language = "ja".to_owned();
5257 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5258 assert!(ja.contains("変更内容"), "{ja}");
5259 assert!(
5260 ja.contains("Added a retry loop around the uploader PUT call."),
5261 "{ja}"
5262 );
5263 }
5264
5265 #[test]
5266 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5267 let mut state = run_state();
5268 state.reviews = vec![
5269 review_round(
5270 1,
5271 vec![review_record(1, "alpha", "found a race, sent back")],
5272 ),
5273 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5274 ];
5275 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5276 assert!(en.contains("Review verdict"), "{en}");
5277 assert!(en.contains("race is fixed, clean"), "{en}");
5278 assert!(
5279 !en.contains("found a race, sent back"),
5280 "only the round that actually cleared the merge should show: {en}"
5281 );
5282
5283 state.config.graph.language = "ja".to_owned();
5284 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5285 assert!(ja.contains("レビューの結論"), "{ja}");
5286 assert!(ja.contains("レビュアー"), "{ja}");
5287 assert!(ja.contains("race is fixed, clean"), "{ja}");
5288 }
5289
5290 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5296 ReviewRecord {
5297 attempts: 0,
5298 reviewer,
5299 agent: agent.to_owned(),
5300 summary: String::new(),
5301 findings: Vec::new(),
5302 vote: None,
5303 failed: Some(reason.to_owned()),
5304 duration_ms: 0,
5305 }
5306 }
5307
5308 #[test]
5309 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5310 let mut state = run_state();
5311 state.reviews = vec![review_round(
5312 1,
5313 vec![
5314 review_record(1, "alpha", "clean, nothing to add"),
5315 unanswered_review_record(2, "beta", "timed out"),
5316 ],
5317 )];
5318 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5319 assert!(en.contains("clean, nothing to add"), "{en}");
5320 assert!(
5321 en.contains("produced no answer: timed out"),
5322 "a seat that never answered must say so, not render a blank box: {en}"
5323 );
5324 assert!(
5325 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5326 "no reviewer box may be left empty: {en}"
5327 );
5328
5329 state.config.graph.language = "ja".to_owned();
5330 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5331 assert!(ja.contains("回答なし: timed out"), "{ja}");
5332 }
5333
5334 #[test]
5335 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5336 let html = panel();
5337 assert!(!html.contains("<script"), "no script survives the csp");
5338 assert!(!html.contains("<form"), "form-action is 'none'");
5339 let pr = green_pr();
5340 assert_eq!(
5341 html.matches("http").count(),
5342 html.matches(pr.url.as_str()).count(),
5343 "the only http url in the panel is the pull request's own link"
5344 );
5345 }
5346
5347 #[test]
5348 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5349 let html = panel();
5350 assert!(
5351 html.contains(">+</span>"),
5352 "an added line carries a `+` in the gutter, not only a background"
5353 );
5354 assert!(
5355 html.contains(">-</span>"),
5356 "a removed line carries a `-` in the gutter, not only a background"
5357 );
5358 assert!(
5359 html.contains(">new line</span>"),
5360 "the marker is moved to the gutter, so the body is printed once without it"
5361 );
5362 }
5363
5364 #[test]
5365 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5366 let total = DIFF_MAX_LINES + 100;
5367 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5368 let html = approval_panel(
5369 &run_state(),
5370 &green_pr(),
5371 NUMSTAT,
5372 &diff,
5373 &[],
5374 "feat: something long",
5375 );
5376 assert!(
5377 html.contains(&format!("100 of {total} diff lines omitted")),
5378 "the note must say exactly how much was cut"
5379 );
5380 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5381 assert!(
5382 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5383 "nothing past the threshold is rendered"
5384 );
5385 assert!(
5386 html.contains("/repo/magi"),
5387 "the note says where the rest is"
5388 );
5389 }
5390
5391 #[test]
5392 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5393 let html = approval_panel(
5394 &run_state(),
5395 &green_pr(),
5396 "1\t2\tsrc/<b>&\"x\"'.rs",
5397 "",
5398 &[],
5399 "subject",
5400 );
5401 assert!(html.contains("src/<b>&"x"'.rs"));
5402 assert!(
5403 !html.contains("<b>"),
5404 "an agent-influenced path must never become markup"
5405 );
5406 }
5407
5408 #[tokio::test]
5409 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5410 let a = std::path::PathBuf::from("/repo/a");
5411 let b = std::path::PathBuf::from("/repo/b");
5412
5413 let held = repo_merge_lock(&a).lock_owned().await;
5414
5415 assert!(
5418 repo_merge_lock(&a).try_lock().is_err(),
5419 "a second merge into the same repository must not proceed concurrently"
5420 );
5421
5422 assert!(
5426 repo_merge_lock(&b).try_lock().is_ok(),
5427 "a different repository's merge lock must be independent"
5428 );
5429
5430 drop(held);
5431 assert!(
5432 repo_merge_lock(&a).try_lock().is_ok(),
5433 "the lock is released once the holder is done"
5434 );
5435 }
5436
5437 #[test]
5438 fn only_the_merge_choice_merges_and_silence_holds() {
5439 let table = [
5440 (None, Approval::Hold),
5441 (Some("merge"), Approval::Merge),
5442 (Some(" merge\n"), Approval::Merge),
5443 (Some("hold"), Approval::Hold),
5444 (Some(""), Approval::Hold),
5445 (Some("yes"), Approval::Hold),
5446 ];
5447 for (answer, want) in table {
5448 assert_eq!(
5449 approval(answer),
5450 want,
5451 "answer {answer:?} must resolve to {want:?}"
5452 );
5453 }
5454 }
5455
5456 #[tokio::test]
5457 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5458 let mut state = landing_state();
5459 state.config.graph.land_approval = true;
5460 let pr = green_pr();
5461
5462 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5463 .await
5464 .unwrap();
5465 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5466 assert!(
5467 !state.parked,
5468 "approval_gate itself never sets `parked`; only its caller does"
5469 );
5470
5471 let store = ask::Questions::open();
5472 let filed: Vec<_> = store
5473 .list()
5474 .into_iter()
5475 .filter(|q| q.run == state.id)
5476 .collect();
5477 assert_eq!(filed.len(), 1, "exactly one question is filed");
5478 assert_eq!(filed[0].node, APPROVAL_NODE);
5479 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5480 assert!(filed[0].status.open());
5481
5482 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5486 .await
5487 .unwrap();
5488 assert_eq!(again, ApprovalGate::Pending);
5489 let still_one = store
5490 .list()
5491 .into_iter()
5492 .filter(|q| q.run == state.id)
5493 .count();
5494 assert_eq!(
5495 still_one, 1,
5496 "asking twice must not double-file the question"
5497 );
5498 }
5499
5500 #[tokio::test]
5501 async fn approving_the_existing_question_is_read_back_as_approved() {
5502 crate::run::pin_test_home();
5503 let mut state = run_state();
5504 state.config.graph.land_approval = true;
5505 let pr = green_pr();
5506 assert_eq!(
5507 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5508 .await
5509 .unwrap(),
5510 ApprovalGate::Pending
5511 );
5512
5513 let store = ask::Questions::open();
5514 let mut q = store
5515 .list()
5516 .into_iter()
5517 .find(|q| q.run == state.id)
5518 .expect("filed above");
5519 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5520 store.put(&mut q).unwrap();
5521
5522 assert_eq!(
5523 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5524 .await
5525 .unwrap(),
5526 ApprovalGate::Approved
5527 );
5528 }
5529
5530 #[tokio::test]
5531 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5532 crate::run::pin_test_home();
5533 let store = ask::Questions::open();
5534
5535 let mut held_state = run_state();
5536 held_state.config.graph.land_approval = true;
5537 let pr = green_pr();
5538 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5539 .await
5540 .unwrap();
5541 let mut q = store
5542 .list()
5543 .into_iter()
5544 .find(|q| q.run == held_state.id)
5545 .expect("filed above");
5546 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5547 store.put(&mut q).unwrap();
5548 assert_eq!(
5549 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5550 .await
5551 .unwrap(),
5552 ApprovalGate::Held
5553 );
5554
5555 let mut abandoned_state = run_state();
5556 abandoned_state.config.graph.land_approval = true;
5557 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5558 .await
5559 .unwrap();
5560 let mut q = store
5561 .list()
5562 .into_iter()
5563 .find(|q| q.run == abandoned_state.id)
5564 .expect("filed above");
5565 q.abandon("no answer within the timeout");
5566 store.put(&mut q).unwrap();
5567 assert_eq!(
5568 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5569 .await
5570 .unwrap(),
5571 ApprovalGate::Held,
5572 "silence must never merge"
5573 );
5574 }
5575
5576 fn contested() -> ContestedHandoff {
5577 let finding = |id: &str, n: u32| crate::verdict::Finding {
5578 id: id.to_owned(),
5579 severity: crate::verdict::Severity::Major,
5580 file: Some("src/a.rs".to_owned()),
5581 line: Some(n),
5582 title: format!("problem {id}"),
5583 detail: String::new(),
5584 };
5585 ContestedHandoff {
5586 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5587 rejecters: vec![(1, "alpha".to_owned())],
5588 }
5589 }
5590
5591 #[test]
5592 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5593 let mut state = run_state();
5594 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5595 state.contested_handoff = Some(contested());
5596 assert!(contested_to_ask(&state).is_some());
5597 state.config.graph.hold_contested_merge = false;
5598 assert!(
5599 contested_to_ask(&state).is_none(),
5600 "the switch restores today"
5601 );
5602 }
5603
5604 #[test]
5605 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5606 let q = ask::Question::new(
5607 "run-1".to_owned(),
5608 APPROVAL_NODE.to_owned(),
5609 "land".to_owned(),
5610 "Merge?".to_owned(),
5611 String::new(),
5612 vec![APPROVE.to_owned(), HOLD.to_owned()],
5613 );
5614 let none = deputy_brief(&q, None);
5615 assert!(none.contains("could not be read"), "{none}");
5616 assert!(none.contains("Silence is a hold"), "{none}");
5617
5618 let mut state = run_state();
5619 state.pr = Some(crate::run::PrRecord {
5620 url: "https://example.test/pull/7".to_owned(),
5621 number: 7,
5622 state: "open".to_owned(),
5623 checks: "green".to_owned(),
5624 round: 0,
5625 rounds: 3,
5626 red_at_merge: Vec::new(),
5627 });
5628 state.contested_handoff = Some(contested());
5629 let b = deputy_brief(&q, Some(&state));
5630 assert!(b.contains("https://example.test/pull/7"), "{b}");
5631 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5632 assert!(b.contains("#1"), "the rejecting seat: {b}");
5633 state.contested_handoff = None;
5634 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5635 }
5636
5637 #[test]
5638 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5639 for lang in ["en", "ja"] {
5640 let mut cfg = crate::config::Config::default();
5641 cfg.graph.language = lang.to_owned();
5642 let w = words(&cfg.graph.language);
5643 let text = w.approval_detail(
5644 "https://github.com/yukimemi/magi/pull/42",
5645 "main",
5646 "feat: x",
5647 Some(&contested()),
5648 );
5649 assert!(text.contains("pull/42"), "{text}");
5650 assert!(
5651 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5652 "{text}"
5653 );
5654 assert!(text.contains("R3-1-5"), "{text}");
5655 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5656 assert!(text.contains("2"), "the rest are counted: {text}");
5657 assert!(text.contains("#1 (alpha)"), "{text}");
5658 }
5659 let plain = words("en").approval_detail("u", "main", "s", None);
5660 assert!(!plain.contains("reject"), "{plain}");
5661 }
5662
5663 #[tokio::test]
5664 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5665 crate::run::pin_test_home();
5666 let mut state = run_state();
5667 state.config.graph.land_approval = false;
5668 state.contested_handoff = Some(contested());
5669 let pr = green_pr();
5670 let c = contested_to_ask(&state);
5671 assert_eq!(
5672 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5673 .await
5674 .unwrap(),
5675 ApprovalGate::Pending,
5676 "silence is a hold"
5677 );
5678 let store = ask::Questions::open();
5679 let filed: Vec<_> = store
5680 .list()
5681 .into_iter()
5682 .filter(|q| q.run == state.id)
5683 .collect();
5684 assert_eq!(filed.len(), 1);
5685 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5686
5687 assert_eq!(
5688 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5689 .await
5690 .unwrap(),
5691 ApprovalGate::Pending
5692 );
5693 let mut q = store
5694 .list()
5695 .into_iter()
5696 .find(|q| q.run == state.id)
5697 .unwrap();
5698 assert_eq!(q.id, filed[0].id, "the same question after a resume");
5699 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5700 store.put(&mut q).unwrap();
5701 assert_eq!(
5702 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5703 .await
5704 .unwrap(),
5705 ApprovalGate::Approved
5706 );
5707 }
5708
5709 #[test]
5710 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5711 let rows = parse_numstat(NUMSTAT);
5712 assert_eq!(
5713 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5714 ["src/web.rs", "src/land.rs", "assets/logo.png"]
5715 );
5716 assert_eq!(rows[2].added, None, "a binary file has no line counts");
5717 }
5718 #[test]
5719 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5720 let mut state = run_state();
5724 state.config.graph.language = "ja".to_owned();
5725 let pr = green_pr();
5726 let commits = ["c1".to_owned()];
5727
5728 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5729 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
5730 assert!(ja.contains("squash されるコミット"), "{ja}");
5731 assert!(ja.contains("レビューコメント"), "{ja}");
5732 assert!(ja.contains("差分"), "{ja}");
5733 assert!(
5734 !ja.contains("Commits being squashed"),
5735 "no English left over"
5736 );
5737
5738 let w = words("ja");
5739 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
5740 assert!(
5741 w.approval_detail("http://x/1", "main", "feat: x", None)
5742 .contains("パネル")
5743 );
5744
5745 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
5747 assert!(ja.contains("feat: x"), "nor is the merge subject");
5748
5749 state.config.graph.language = "en".to_owned();
5752 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5753 assert!(en.contains("Commits being squashed"), "{en}");
5754 assert_eq!(words("Klingon").html_lang, "en");
5755 }
5756
5757 #[test]
5761 fn pick_open_pr_classifies_by_count_and_base() {
5762 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
5763 assert_eq!(
5764 pick_open_pr(one, "main").unwrap(),
5765 OpenPr::One {
5766 url: "https://x/pull/58".into(),
5767 title: "t".into()
5768 }
5769 );
5770 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
5771 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
5772 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
5773 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
5774 assert_eq!(
5775 pick_open_pr(two, "main").unwrap(),
5776 OpenPr::Many(vec!["u1".into(), "u2".into()])
5777 );
5778 assert!(pick_open_pr("not json", "main").is_err());
5779 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
5781 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
5782 }
5783
5784 #[test]
5785 fn pick_merged_pr_picks_the_unique_match() {
5786 let json = r#"[
5787 {"url": "https://github.com/o/r/pull/42", "number": 42,
5788 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
5789 ]"#;
5790 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5791 let found = pick_merged_pr(json, "main", created_at)
5792 .expect("valid json")
5793 .expect("one unambiguous match");
5794 assert_eq!(found.url, "https://github.com/o/r/pull/42");
5795 assert_eq!(found.number, 42);
5796 }
5797
5798 #[test]
5802 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
5803 let json = r#"[
5804 {"url": "https://github.com/o/r/pull/42", "number": 42,
5805 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
5806 {"url": "https://github.com/o/r/pull/43", "number": 43,
5807 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
5808 ]"#;
5809 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5810 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5811 }
5812
5813 #[test]
5817 fn pick_merged_pr_ignores_a_different_base_branch() {
5818 let json = r#"[
5819 {"url": "https://github.com/o/r/pull/42", "number": 42,
5820 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
5821 ]"#;
5822 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5823 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5824 }
5825
5826 #[test]
5829 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
5830 let json = r#"[
5831 {"url": "https://github.com/o/r/pull/42", "number": 42,
5832 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
5833 ]"#;
5834 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5835 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5836 }
5837
5838 #[test]
5839 fn slug_of_pr_url_reads_host_owner_and_repo() {
5840 assert_eq!(
5841 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
5842 Some("github.com/yukimemi/shun")
5843 );
5844 }
5845
5846 #[test]
5847 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
5848 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
5849 assert_eq!(slug_of_pr_url("not a url at all"), None);
5850 assert_eq!(slug_of_pr_url("https://github.com"), None);
5851 }
5852
5853 #[test]
5854 fn slug_of_repo_url_reads_host_owner_and_repo() {
5855 assert_eq!(
5856 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
5857 Some("github.com/yukimemi/magi")
5858 );
5859 assert_eq!(slug_of_repo_url("https://github.com"), None);
5860 }
5861
5862 #[test]
5863 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
5864 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
5865 .expect("same repo, different case");
5866 }
5867
5868 #[test]
5873 fn ensure_same_repo_refuses_a_different_repo() {
5874 let err =
5875 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
5876 let msg = format!("{err:#}");
5877 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
5878 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
5879 }
5880
5881 #[test]
5886 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
5887 let err = ensure_same_repo(
5888 "github.com/yukimemi/magi",
5889 "github.example.com/yukimemi/magi",
5890 )
5891 .unwrap_err();
5892 let msg = format!("{err:#}");
5893 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
5894 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
5895 }
5896
5897 #[tokio::test]
5902 async fn find_external_merge_returns_none_without_a_winner() {
5903 let state = RunState::new(
5904 PathBuf::from("/no/such/repo"),
5905 "main".to_owned(),
5906 "0000000000000000000000000000000000000000".to_owned(),
5907 "irrelevant".to_owned(),
5908 crate::config::Config::default(),
5909 );
5910 assert_eq!(find_external_merge(&state).await.unwrap(), None);
5911 }
5912
5913 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
5914 let mut run = RunState::new(
5915 PathBuf::from(repo),
5916 "main".to_owned(),
5917 "abcdef1234".to_owned(),
5918 "x".to_owned(),
5919 crate::config::Config::default(),
5920 );
5921 run.id = id.to_owned();
5922 run.status = status;
5923 run.pr = Some(crate::run::PrRecord {
5924 number: url.rsplit('/').next().unwrap().parse().unwrap(),
5925 url: url.to_owned(),
5926 state: state.to_owned(),
5927 checks: "red".to_owned(),
5928 round: 0,
5929 rounds: 2,
5930 red_at_merge: Vec::new(),
5931 });
5932 run.save_under(home).unwrap();
5933 }
5934
5935 fn recorded(home: &Path, id: &str) -> String {
5936 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
5937 serde_json::from_str::<RunState>(&body)
5938 .unwrap()
5939 .pr
5940 .unwrap()
5941 .state
5942 }
5943
5944 const PR: &str = "https://github.com/o/r/pull/7";
5945
5946 #[test]
5947 fn write_through_updates_predecessors_and_siblings_only() {
5948 let tmp = tempfile::tempdir().unwrap();
5949 let h = tmp.path();
5950 pr_run(
5951 h,
5952 "20261004-100000-aaaa",
5953 "/repo/r",
5954 RunStatus::Superseded,
5955 PR,
5956 "open",
5957 );
5958 pr_run(
5959 h,
5960 "20261004-100100-bbbb",
5961 "/repo/r",
5962 RunStatus::Blocked,
5963 PR,
5964 "open",
5965 );
5966 pr_run(
5968 h,
5969 "20261004-100200-cccc",
5970 "/repo/r",
5971 RunStatus::Landing,
5972 PR,
5973 "open",
5974 );
5975 pr_run(
5977 h,
5978 "20261004-100300-dddd",
5979 "/repo/other",
5980 RunStatus::Blocked,
5981 "https://github.com/o/other/pull/7",
5982 "open",
5983 );
5984 pr_run(
5986 h,
5987 "20261004-100400-eeee",
5988 "/repo/r",
5989 RunStatus::Blocked,
5990 "https://github.com/o/r/pull/8",
5991 "open",
5992 );
5993 pr_run(
5994 h,
5995 "20261004-100500-ffff",
5996 "/repo/r",
5997 RunStatus::Merged,
5998 PR,
5999 "open",
6000 );
6001 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6002
6003 assert_eq!(
6004 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6005 2
6006 );
6007 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6008 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6009 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6010 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6011 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6012 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6014 assert_eq!(
6016 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6017 0
6018 );
6019 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6020 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6021 }
6022
6023 #[test]
6024 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6025 let tmp = tempfile::tempdir().unwrap();
6026 let h = tmp.path();
6027 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6028 pr_run(
6029 h,
6030 "20261004-100000-aaaa",
6031 "/repo/r",
6032 RunStatus::Superseded,
6033 &url(1),
6034 "open",
6035 );
6036 pr_run(
6037 h,
6038 "20261004-100100-bbbb",
6039 "/repo/r",
6040 RunStatus::Blocked,
6041 &url(2),
6042 "open",
6043 );
6044 pr_run(
6045 h,
6046 "20261004-100200-cccc",
6047 "/repo/r",
6048 RunStatus::Ready,
6049 &url(3),
6050 "open",
6051 );
6052 pr_run(
6053 h,
6054 "20261004-100300-dddd",
6055 "/repo/r",
6056 RunStatus::Ready,
6057 &url(4),
6058 "open",
6059 );
6060 pr_run(
6061 h,
6062 "20261004-100400-eeee",
6063 "/repo/r",
6064 RunStatus::Implementing,
6065 &url(1),
6066 "open",
6067 );
6068 assert_eq!(stale_open_prs(h).len(), 4);
6069
6070 let mut known = BTreeMap::new();
6071 known.insert(url(1), PrLifecycle::Merged);
6072 known.insert(url(2), PrLifecycle::Closed);
6073 known.insert(url(3), PrLifecycle::Open);
6074 assert_eq!(apply_pr_states(h, &known), 2);
6076 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6077 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6078 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6079 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6080 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6081 assert_eq!(apply_pr_states(h, &known), 0);
6082 }
6083
6084 use std::collections::VecDeque;
6087 use std::sync::Mutex;
6088
6089 struct Scripted {
6092 views: Mutex<VecDeque<Seen>>,
6093 merges: Mutex<VecDeque<(bool, String)>>,
6094 fix: Mutex<Option<Fixed>>,
6095 log: Mutex<Vec<&'static str>>,
6096 argvs: Mutex<Vec<Vec<String>>>,
6097 required: Mutex<Option<BTreeSet<String>>>,
6098 merged: Mutex<bool>,
6101 queued: Mutex<bool>,
6102 unreadable_after_merge: Mutex<bool>,
6104 }
6105
6106 impl Scripted {
6107 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6108 Self {
6109 views: Mutex::new(views.into()),
6110 merges: Mutex::new(
6111 merges
6112 .into_iter()
6113 .map(|(ok, m)| (ok, m.to_owned()))
6114 .collect(),
6115 ),
6116 fix: Mutex::new(None),
6117 log: Mutex::new(Vec::new()),
6118 argvs: Mutex::new(Vec::new()),
6119 required: Mutex::new(None),
6120 merged: Mutex::new(false),
6121 queued: Mutex::new(false),
6122 unreadable_after_merge: Mutex::new(false),
6123 }
6124 }
6125 fn argvs(&self) -> Vec<Vec<String>> {
6126 self.argvs.lock().unwrap().clone()
6127 }
6128 fn calls(&self) -> Vec<&'static str> {
6129 self.log.lock().unwrap().clone()
6130 }
6131 }
6132
6133 impl Forge for Scripted {
6134 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6135 self.log.lock().unwrap().push("view");
6136 if *self.unreadable_after_merge.lock().unwrap()
6137 && !self.argvs.lock().unwrap().is_empty()
6138 {
6139 anyhow::bail!("forge unreachable");
6140 }
6141 let mut v = self.views.lock().unwrap();
6142 let mut seen = if v.len() > 1 {
6143 v.pop_front().unwrap()
6144 } else {
6145 v[0].clone()
6146 };
6147 if *self.merged.lock().unwrap() {
6148 seen.pr.state = PrLifecycle::Merged;
6149 }
6150 Ok(seen)
6151 }
6152 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6153 self.log.lock().unwrap().push("merge");
6154 self.argvs.lock().unwrap().push(argv.to_vec());
6155 let out = self
6156 .merges
6157 .lock()
6158 .unwrap()
6159 .pop_front()
6160 .expect("unscripted merge");
6161 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6162 {
6163 *self.merged.lock().unwrap() = true;
6164 }
6165 Ok(out)
6166 }
6167 async fn poll(&self) {
6168 self.log.lock().unwrap().push("poll");
6169 }
6170 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6171 self.required.lock().unwrap().clone()
6172 }
6173 async fn fix(
6174 &self,
6175 _state: &mut RunState,
6176 _pr: &PrState,
6177 _round: usize,
6178 _budget: usize,
6179 _reason: &str,
6180 _logs: &str,
6181 ) -> Result<Fixed> {
6182 self.log.lock().unwrap().push("fix");
6183 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6184 }
6185 }
6186
6187 const REFUSED: &str =
6188 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6189
6190 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6191 let mut pr = green_pr();
6192 pr.checks = checks;
6193 pr.blocking = Blocking::of(merge_state);
6194 if !comments {
6195 pr.review_comments.clear();
6196 }
6197 Seen {
6198 pr,
6199 title: "feat: x".to_owned(),
6200 failing_urls: Vec::new(),
6201 head: head.to_owned(),
6202 rollup_head: head.to_owned(),
6203 merge_state: merge_state.to_owned(),
6204 contexts: Vec::new(),
6205 base: "main".to_owned(),
6206 }
6207 }
6208
6209 fn landing_state() -> RunState {
6210 crate::run::pin_test_home();
6211 let mut state = run_state();
6212 state.config.graph.land_approval = false;
6213 state
6214 }
6215
6216 #[test]
6217 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6218 assert!(!awaiting_new_head(None, "aaa"));
6219 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6220 assert!(awaiting_new_head(Some("abc123"), "def456"));
6221 assert!(awaiting_new_head(Some("abc123"), ""));
6222 }
6223
6224 #[test]
6225 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6226 let open = |c, m: &str| seen("a", c, m, false);
6227 let table = [
6228 (None, false, Refused::Pending),
6229 (
6230 Some(open(Checks::Pending, "BLOCKED")),
6231 false,
6232 Refused::Pending,
6233 ),
6234 (
6235 Some(open(Checks::Unknown, "BLOCKED")),
6236 false,
6237 Refused::Pending,
6238 ),
6239 (
6240 Some(open(Checks::Green, "UNKNOWN")),
6241 false,
6242 Refused::Pending,
6243 ),
6244 (Some(open(Checks::Green, "")), false, Refused::Pending),
6245 (
6246 Some(open(Checks::Green, "BLOCKED")),
6247 false,
6248 Refused::Recheck,
6249 ),
6250 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6251 ];
6252 for (after, rechecked, want) in table {
6253 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6254 }
6255 let mut closed = open(Checks::Green, "CLEAN");
6256 closed.pr.state = PrLifecycle::Closed;
6257 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6258 }
6259
6260 #[tokio::test]
6261 async fn a_normal_landing_merges_on_the_first_look() {
6262 let mut state = landing_state();
6263 let forge = Scripted::new(
6264 vec![seen("a", Checks::Green, "CLEAN", false)],
6265 vec![(true, "")],
6266 );
6267 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6268 .await
6269 .unwrap();
6270 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6272 assert_eq!(state.status, RunStatus::Merged);
6273 }
6274
6275 #[tokio::test]
6276 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6277 let mut state = landing_state();
6278 let forge = Scripted::new(
6279 vec![seen("a", Checks::Green, "CLEAN", false)],
6280 std::iter::repeat_n((true, ""), 100).collect(),
6281 );
6282 *forge.queued.lock().unwrap() = true;
6283 let task = async {
6284 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6285 .await
6286 .unwrap();
6287 };
6288 task.await;
6291 assert_ne!(state.status, RunStatus::Merged);
6292 }
6293
6294 #[tokio::test]
6295 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6296 let mut state = landing_state();
6297 let forge = Scripted::new(
6298 vec![seen("a", Checks::Green, "CLEAN", false)],
6299 std::iter::repeat_n((true, ""), 100).collect(),
6300 );
6301 *forge.unreadable_after_merge.lock().unwrap() = true;
6302 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6303 .await
6304 .ok();
6305 assert_ne!(state.status, RunStatus::Merged);
6306 }
6307
6308 #[tokio::test]
6309 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6310 let mut state = landing_state();
6311 let forge = Scripted::new(
6312 vec![
6313 seen("old", Checks::Green, "CLEAN", true),
6314 seen("old", Checks::Green, "CLEAN", true),
6316 seen("new", Checks::Pending, "BLOCKED", true),
6317 seen("new", Checks::Green, "CLEAN", true),
6318 ],
6319 vec![(true, "")],
6320 );
6321 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6322 head: "NEW".to_owned(),
6323 });
6324 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6325 .await
6326 .unwrap();
6327 assert_eq!(
6328 forge.calls(),
6329 [
6330 "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
6331 "view"
6332 ]
6333 );
6334 assert_eq!(state.status, RunStatus::Merged);
6335 }
6336
6337 #[tokio::test]
6338 async fn a_head_that_never_arrives_stops_naming_both_commits() {
6339 let mut state = landing_state();
6340 let forge = Scripted::new(
6341 vec![
6342 seen("old", Checks::Green, "CLEAN", true),
6343 seen("someone-elses", Checks::Green, "CLEAN", true),
6344 ],
6345 vec![],
6346 );
6347 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6348 head: "mine".to_owned(),
6349 });
6350 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6351 .await
6352 .unwrap();
6353 assert!(!forge.calls().contains(&"merge"));
6354 let why = state.merge.as_ref().unwrap().detail.clone();
6355 assert!(
6356 why.contains("mine") && why.contains("someone-elses"),
6357 "{why}"
6358 );
6359 assert_eq!(state.status, RunStatus::Blocked);
6360 }
6361
6362 #[tokio::test]
6363 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6364 let mut state = landing_state();
6365 let forge = Scripted::new(
6366 vec![
6367 seen("a", Checks::Green, "CLEAN", false),
6368 seen("a", Checks::Green, "CLEAN", false),
6369 seen("a", Checks::Pending, "BLOCKED", false),
6370 seen("a", Checks::Pending, "BLOCKED", false),
6371 seen("a", Checks::Green, "CLEAN", false),
6372 ],
6373 vec![(false, REFUSED), (true, "")],
6374 );
6375 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6376 .await
6377 .unwrap();
6378 assert_eq!(
6379 forge.calls(),
6380 [
6381 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6382 "view"
6383 ]
6384 );
6385 assert_eq!(state.status, RunStatus::Merged);
6386 }
6387
6388 #[tokio::test]
6389 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6390 let mut state = landing_state();
6391 let forge = Scripted::new(
6392 vec![
6393 seen("a", Checks::Green, "CLEAN", false),
6394 seen("a", Checks::Green, "BLOCKED", false),
6395 ],
6396 vec![(false, REFUSED), (false, REFUSED)],
6397 );
6398 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6399 .await
6400 .unwrap();
6401 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6403 let why = state.merge.as_ref().unwrap().detail.clone();
6404 assert!(
6405 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6406 "{why}"
6407 );
6408 assert_eq!(state.status, RunStatus::Blocked);
6409 }
6410
6411 #[test]
6412 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6413 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6414 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6415 assert_eq!(bound_head("old", "old", Some("new")), None);
6417 assert_eq!(bound_head("new", "old", Some("new")), None);
6419 assert_eq!(bound_head("new", "old", None), None);
6420 assert_eq!(bound_head("", "", None), None);
6422 assert_eq!(bound_head("", "", Some("new")), None);
6423 assert_eq!(bound_head("abc", "", None), None);
6424 }
6425
6426 fn view_json(head: &str) -> String {
6427 format!(
6428 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6429 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6430 "reviews":[],"comments":[]}}"#
6431 )
6432 }
6433
6434 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6435 format!(
6436 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6437 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6438 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6439 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6440 )
6441 }
6442
6443 #[test]
6444 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6445 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6448 assert_eq!(s.rollup_head, "old");
6449 assert_eq!(s.pr.checks, Checks::Red);
6450 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6451 assert_eq!(s.failing_urls.len(), 1);
6452 }
6453
6454 #[test]
6455 fn checks_come_from_the_node_not_the_view() {
6456 let view = view_json("new").replace(
6457 r#""reviews""#,
6458 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6459 );
6460 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6461 assert_eq!(s.pr.checks, Checks::Green);
6462 assert!(s.pr.failing.is_empty());
6463 assert_eq!(
6464 bound_head(&s.head, &s.rollup_head, Some("new")),
6465 Some("new")
6466 );
6467 }
6468
6469 #[test]
6470 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6471 for node in [
6472 None,
6473 Some("not json".to_owned()),
6474 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6475 Some(node_json("new", "SUCCESS", true)),
6476 ] {
6477 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6478 assert!(s.rollup_head.is_empty());
6479 assert_eq!(s.pr.checks, Checks::Unknown);
6480 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6481 }
6482 }
6483
6484 #[test]
6485 fn the_merge_command_is_pinned_to_the_observed_head() {
6486 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6487 let at = argv
6488 .iter()
6489 .position(|a| a == "--match-head-commit")
6490 .unwrap();
6491 assert_eq!(argv[at + 1], "deadbeef");
6492 }
6493
6494 #[tokio::test]
6495 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6496 let mut state = landing_state();
6497 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6500 stale.rollup_head = "old".to_owned();
6501 let forge = Scripted::new(
6502 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6503 vec![],
6504 );
6505 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6506 head: "new".to_owned(),
6507 });
6508 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6509 .await
6510 .unwrap();
6511 assert!(!forge.calls().contains(&"merge"));
6512 assert_eq!(state.status, RunStatus::Blocked);
6513 let why = state.merge.as_ref().unwrap().detail.clone();
6514 assert!(why.contains("new") && why.contains("old"), "{why}");
6515 }
6516
6517 #[test]
6518 fn a_refusal_read_against_another_commits_checks_is_pending() {
6519 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6520 after.rollup_head = "old".to_owned();
6521 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6522 }
6523
6524 #[tokio::test]
6525 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6526 let mut state = landing_state();
6527 let forge = Scripted::new(
6528 vec![seen("a", Checks::Red, "CLEAN", false)],
6529 vec![(true, "")],
6530 );
6531 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6532 .await
6533 .unwrap();
6534 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6535 assert_eq!(state.status, RunStatus::Merged);
6536 }
6537
6538 #[tokio::test]
6539 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6540 let mut state = landing_state();
6541 let forge = Scripted::new(
6542 vec![
6543 seen("a", Checks::Green, "CLEAN", false),
6544 seen("a", Checks::Green, "CLEAN", false),
6545 seen("b", Checks::Green, "BLOCKED", false),
6547 seen("b", Checks::Green, "CLEAN", false),
6548 ],
6549 vec![(false, REFUSED), (true, "")],
6550 );
6551 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6552 .await
6553 .unwrap();
6554 assert_eq!(
6555 forge.calls(),
6556 [
6557 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6558 ]
6559 );
6560 assert_eq!(state.status, RunStatus::Merged);
6561 }
6562
6563 fn merged_view(head: &str) -> Seen {
6564 let mut m = seen(head, Checks::Green, "CLEAN", false);
6565 m.pr.state = PrLifecycle::Merged;
6566 m
6567 }
6568
6569 fn has(argv: &[String], flag: &str) -> bool {
6570 argv.iter().any(|a| a == flag)
6571 }
6572
6573 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6574 let at = argv.iter().position(|a| a == flag)?;
6575 argv.get(at + 1).map(String::as_str)
6576 }
6577
6578 const URL: &str = "https://github.com/o/r/pull/42";
6579
6580 #[tokio::test]
6581 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6582 let mut state = landing_state();
6583 let forge = Scripted::new(
6584 vec![
6585 seen("abc", Checks::Green, "CLEAN", false),
6586 seen("abc", Checks::Green, "CLEAN", false),
6587 ],
6588 vec![(true, "")],
6589 );
6590 land_with(&mut state, URL, &forge).await.unwrap();
6591 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6592 let argv = &forge.argvs()[0];
6593 assert!(has(argv, "--squash") && has(argv, "--subject"));
6594 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6595 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6596 assert_eq!(state.status, RunStatus::Merged);
6597 assert!(state.land_armed_head.is_none());
6598 }
6599
6600 #[tokio::test]
6601 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6602 let mut state = landing_state();
6603 state.land_armed_head = Some("a".to_owned());
6604 let forge = Scripted::new(
6605 vec![seen("a", Checks::Green, "CLEAN", false)],
6606 vec![(true, ""), (true, "")],
6607 );
6608 land_with(&mut state, URL, &forge).await.unwrap();
6609 let argvs = forge.argvs();
6610 assert!(has(&argvs[0], "--disable-auto"));
6611 assert!(!has(&argvs[1], "--auto"));
6612 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6613 assert_eq!(state.status, RunStatus::Merged);
6614 assert!(state.land_armed_head.is_none());
6615 }
6616
6617 #[tokio::test]
6618 async fn an_approval_never_carries_over_to_a_new_head() {
6619 crate::run::pin_test_home();
6620 let mut state = run_state();
6621 state.config.graph.land_approval = true;
6622 let pr = green_pr();
6623 let store = ask::Questions::open();
6624
6625 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6626 .await
6627 .unwrap();
6628 let mut q = store
6629 .list()
6630 .into_iter()
6631 .find(|q| q.run == state.id)
6632 .unwrap();
6633 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6634 store.put(&mut q).unwrap();
6635 assert_eq!(
6636 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6637 .await
6638 .unwrap(),
6639 ApprovalGate::Approved,
6640 "the same head keeps its approval"
6641 );
6642
6643 assert_eq!(
6645 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6646 .await
6647 .unwrap(),
6648 ApprovalGate::Pending
6649 );
6650 let all: Vec<_> = store
6651 .list()
6652 .into_iter()
6653 .filter(|q| q.run == state.id)
6654 .collect();
6655 assert_eq!(all.len(), 2);
6656
6657 state.land_approval = None;
6659 assert_eq!(
6660 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6661 .await
6662 .unwrap(),
6663 ApprovalGate::Pending
6664 );
6665 let open = store
6666 .list()
6667 .into_iter()
6668 .filter(|q| q.run == state.id && q.status.open())
6669 .count();
6670 assert_eq!(open, 1, "the superseded question was retired");
6671 }
6672
6673 #[tokio::test]
6674 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6675 let mut state = landing_state();
6676 let forge = Scripted::new(
6677 vec![
6678 seen("a", Checks::Green, "CLEAN", false),
6679 seen("b", Checks::Green, "CLEAN", false),
6681 ],
6682 vec![(true, "")],
6683 );
6684 land_with(&mut state, URL, &forge).await.unwrap();
6685 let argvs = forge.argvs();
6686 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6687 assert!(!has(&argvs[0], "--auto"));
6688 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6689 assert_eq!(state.status, RunStatus::Merged);
6690 }
6691
6692 fn passing(label: &str) -> CheckInfo {
6693 CheckInfo {
6694 label: label.to_owned(),
6695 verdict: Verdict::Pass,
6696 required: Some(false),
6697 }
6698 }
6699
6700 fn names(xs: &[&str]) -> BTreeSet<String> {
6701 xs.iter().map(|x| (*x).to_owned()).collect()
6702 }
6703
6704 #[test]
6705 fn a_required_check_the_rollup_never_listed_is_named() {
6706 let req = names(&["build"]);
6707 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6708 assert!(why.contains("never reported: build"), "{why}");
6709 assert!(!why.contains("probably waiting for a review"), "{why}");
6710 }
6711
6712 #[test]
6713 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6714 let why = waiting_on("BLOCKED", &[passing("review")], None);
6715 assert!(why.contains("could not be read"), "{why}");
6716 assert!(!why.contains("probably waiting for a review"), "{why}");
6717 }
6718
6719 #[test]
6720 fn all_required_reported_keeps_the_review_guess() {
6721 let req = names(&["build"]);
6722 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6723 assert!(why.contains("probably waiting for a review"), "{why}");
6724 assert!(!why.contains("never reported"), "{why}");
6725 }
6726
6727 #[test]
6728 fn required_names_match_the_rollup_ignoring_case_only() {
6729 let req = names(&["Build"]);
6730 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6731 assert!(!why.contains("never reported"), "{why}");
6732 }
6733
6734 #[test]
6735 fn required_contexts_are_read_from_protection_and_rulesets() {
6736 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
6737 assert_eq!(
6738 parse_classic_required(classic),
6739 Some(names(&["build", "lint"]))
6740 );
6741 let rules = r#"[{"type":"pull_request","parameters":{}},
6742 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
6743 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
6744 assert_eq!(parse_ruleset_required("nope"), None);
6745 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
6746 }
6747
6748 #[test]
6749 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
6750 let shown = BTreeSet::new();
6751 let ok = seen("a", Checks::Green, "CLEAN", false);
6752 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
6753 assert!(guard(Some(&ok)));
6754 assert!(!guard(None));
6755 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
6756 let mut stale = ok.clone();
6757 stale.rollup_head = "old".to_owned();
6758 assert!(!guard(Some(&stale)));
6759 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
6760 assert!(!guard(Some(&merged_view("a"))));
6761 }
6762
6763 #[test]
6764 fn the_rollup_node_carries_whether_each_check_is_required() {
6765 let node = node_json("new", "SUCCESS", false)
6766 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
6767 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
6768 assert_eq!(s.contexts.len(), 1);
6769 assert_eq!(s.contexts[0].required, Some(true));
6770 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
6771 assert_eq!(s.contexts[0].required, None);
6772 }
6773
6774 #[tokio::test]
6775 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
6776 let mut state = landing_state();
6777 state.land_armed_head = Some("a".to_owned());
6778 let forge = Scripted::new(
6779 vec![seen("a", Checks::Green, "CLEAN", true)],
6780 vec![(false, "disable exploded")],
6781 );
6782 land_with(&mut state, URL, &forge).await.unwrap();
6783 assert!(!forge.calls().contains(&"fix"));
6784 assert_eq!(state.status, RunStatus::Blocked);
6785 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
6786 let why = state.merge.as_ref().unwrap().detail.clone();
6787 assert!(why.contains("disable exploded"), "{why}");
6788 }
6789}