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
1523#[derive(Debug, Clone, PartialEq, Eq)]
1525pub(crate) struct CheckView {
1526 pub name: String,
1527 pub verdict: Verdict,
1528 pub run: Option<String>,
1530 pub url: Option<String>,
1531}
1532
1533#[derive(Debug, Clone, PartialEq, Eq)]
1537pub(crate) struct RollupView {
1538 pub url: String,
1539 pub number: u64,
1540 pub state: PrLifecycle,
1541 pub head: String,
1542 pub checks: Vec<CheckView>,
1543}
1544
1545pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1548 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1549 let state = match raw.state.to_ascii_uppercase().as_str() {
1550 "OPEN" => PrLifecycle::Open,
1551 "MERGED" => PrLifecycle::Merged,
1552 "CLOSED" => PrLifecycle::Closed,
1553 other => bail!("unknown pull request state `{other}`"),
1554 };
1555 let checks = raw
1556 .status_check_rollup
1557 .iter()
1558 .map(|c| CheckView {
1559 name: c.label(),
1560 verdict: c.verdict(),
1561 run: c.url().and_then(run_of),
1562 url: c.url().map(str::to_owned),
1563 })
1564 .collect();
1565 Ok(RollupView {
1566 url: raw.url,
1567 number: raw.number,
1568 state,
1569 head: raw.head_ref_oid,
1570 checks,
1571 })
1572}
1573
1574pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1584 let view = gh(
1585 repo,
1586 &[
1587 "pr".to_owned(),
1588 "view".to_owned(),
1589 pr_url.to_owned(),
1590 "--json".to_owned(),
1591 "state".to_owned(),
1592 ],
1593 )
1594 .await?;
1595 if !view.0 {
1596 bail!("gh pr view {pr_url}: {}", view.1);
1597 }
1598 Ok(parse_pr(&view.1)?.state)
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1608pub struct ExternalMerge {
1609 pub url: String,
1611 pub number: u64,
1613}
1614
1615#[derive(Debug, Deserialize)]
1616#[serde(rename_all = "camelCase")]
1617struct GhMergedPr {
1618 url: String,
1619 number: u64,
1620 merged_at: String,
1621 base_ref_name: String,
1622}
1623
1624fn pick_merged_pr(
1638 json: &str,
1639 base_branch: &str,
1640 created_at: Timestamp,
1641) -> Result<Option<ExternalMerge>> {
1642 let raw: Vec<GhMergedPr> =
1643 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1644 let mut matches: Vec<ExternalMerge> = Vec::new();
1645 for pr in raw {
1646 if pr.base_ref_name != base_branch {
1647 continue;
1648 }
1649 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1650 continue;
1651 };
1652 if merged_at < created_at {
1653 continue;
1654 }
1655 matches.push(ExternalMerge {
1656 url: pr.url,
1657 number: pr.number,
1658 });
1659 }
1660 if matches.len() == 1 {
1661 Ok(matches.pop())
1662 } else {
1663 Ok(None)
1664 }
1665}
1666
1667#[derive(Debug, Clone, PartialEq, Eq)]
1669pub enum OpenPr {
1670 None,
1672 One {
1674 url: String,
1676 title: String,
1678 },
1679 Many(Vec<String>),
1681}
1682
1683#[derive(Debug, Deserialize)]
1684#[serde(rename_all = "camelCase")]
1685struct GhOpenPr {
1686 url: String,
1690 #[serde(default)]
1691 title: String,
1692 base_ref_name: String,
1693}
1694
1695pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1701 let raw: Vec<GhOpenPr> =
1702 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1703 let mut hits: Vec<GhOpenPr> = raw
1704 .into_iter()
1705 .filter(|p| p.base_ref_name == base)
1706 .collect();
1707 Ok(match hits.len() {
1708 0 => OpenPr::None,
1709 1 => {
1710 let p = hits.remove(0);
1711 OpenPr::One {
1712 url: p.url,
1713 title: p.title,
1714 }
1715 }
1716 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1717 })
1718}
1719
1720pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1724 let (ok, out) = gh(
1725 repo,
1726 &[
1727 "pr".to_owned(),
1728 "list".to_owned(),
1729 "--head".to_owned(),
1730 branch.to_owned(),
1731 "--base".to_owned(),
1732 base.to_owned(),
1733 "--state".to_owned(),
1734 "open".to_owned(),
1735 "--json".to_owned(),
1736 "number,url,title,baseRefName".to_owned(),
1737 ],
1738 )
1739 .await?;
1740 if !ok {
1741 bail!("gh pr list failed: {out}");
1742 }
1743 pick_open_pr(&out, base)
1744}
1745
1746#[derive(Debug, Deserialize)]
1747#[serde(rename_all = "camelCase")]
1748struct GhPrHead {
1749 head_ref_name: String,
1750 base_ref_name: String,
1751 state: String,
1752 is_cross_repository: bool,
1755 head_ref_oid: String,
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq)]
1762pub struct Refusal {
1763 pub retry: bool,
1766 pub why: String,
1768}
1769
1770impl Refusal {
1771 fn final_(why: String) -> Self {
1772 Self { retry: false, why }
1773 }
1774}
1775
1776pub fn closable(
1785 json: &str,
1786 branch: &str,
1787 base: &str,
1788 verified: &[String],
1789) -> std::result::Result<(), Refusal> {
1790 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1791 retry: true,
1792 why: format!("could not read the pull request ({e})"),
1793 })?;
1794 if pr.head_ref_name != branch {
1795 return Err(Refusal::final_(format!(
1796 "its head is `{}`, not this run's `{branch}`",
1797 pr.head_ref_name
1798 )));
1799 }
1800 if pr.is_cross_repository {
1801 return Err(Refusal::final_("its head lives in a fork".to_owned()));
1802 }
1803 if pr.base_ref_name != base {
1804 return Err(Refusal::final_(format!(
1805 "it targets `{}`, not `{base}`",
1806 pr.base_ref_name
1807 )));
1808 }
1809 if !pr.state.eq_ignore_ascii_case("open") {
1810 return Err(Refusal::final_(format!(
1811 "it is already {}",
1812 pr.state.to_ascii_lowercase()
1813 )));
1814 }
1815 if !verified.contains(&pr.head_ref_oid) {
1819 return Err(Refusal {
1820 retry: true,
1821 why: format!(
1822 "its head {} is not a commit this run checked against the base",
1823 crate::already::short_sha(&pr.head_ref_oid)
1824 ),
1825 });
1826 }
1827 Ok(())
1828}
1829
1830async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
1835 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
1836 return false;
1837 };
1838 is_forge_url(url.trim())
1839}
1840
1841fn is_forge_url(url: &str) -> bool {
1842 url.contains("://") && !url.starts_with("file://")
1843 || url
1844 .split_once(':')
1845 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
1846}
1847
1848fn forge_unavailable(message: &str) -> bool {
1851 message.contains("known GitHub host") || message.contains("spawn gh")
1852}
1853
1854pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
1857 let how = match evidence.proof {
1858 crate::already::Proof::PatchId => format!(
1859 "carried by commit {} on `{base}` with the same patch",
1860 evidence.names()
1861 ),
1862 crate::already::Proof::Ancestry => {
1863 format!("already in the history of `{base}` as {}", evidence.names())
1864 }
1865 crate::already::Proof::Tree => format!(
1866 "already part of `{base}` (merging this branch changes nothing at {})",
1867 crate::already::short_sha(&evidence.tip)
1868 ),
1869 };
1870 format!(
1871 "Closing: everything this branch adds is {how}, so there is nothing left to \
1872 land. This pull request was closed automatically after that was verified; \
1873 reopen it if you disagree."
1874 )
1875}
1876
1877pub async fn close_superseded_pr(
1889 state: &mut RunState,
1890 branch: &str,
1891 evidence: &crate::already::Evidence,
1892 verified: &[String],
1893) -> Result<std::result::Result<String, String>> {
1894 let repo = state.repo.clone();
1895 let base = state.base_branch.clone();
1896 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
1897 Some(p) => p.url.clone(),
1898 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
1903 return Ok(Err(
1904 "the remote is not a forge, so there is no pull request".to_owned(),
1905 ));
1906 }
1907 None => match find_open_pr(&repo, branch, &base).await {
1908 Err(e) if forge_unavailable(&format!("{e:#}")) => {
1909 return Ok(Err(format!("no forge to ask: {e:#}")));
1910 }
1911 Err(e) => return Err(e),
1912 Ok(OpenPr::One { url, .. }) => url,
1913 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
1914 Ok(OpenPr::Many(urls)) => {
1915 return Ok(Err(format!(
1916 "{} open pull requests name it; not choosing between them",
1917 urls.len()
1918 )));
1919 }
1920 },
1921 };
1922 let (ok, view) = gh(
1923 &repo,
1924 &[
1925 "pr".to_owned(),
1926 "view".to_owned(),
1927 url.clone(),
1928 "--json".to_owned(),
1929 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
1930 ],
1931 )
1932 .await?;
1933 if !ok {
1934 bail!("gh pr view {url} failed: {view}");
1935 }
1936 if let Err(refusal) = closable(&view, branch, &base, verified) {
1937 if refusal.retry {
1940 bail!("left {url} open: {}", refusal.why);
1941 }
1942 return Ok(Err(format!("left {url} open: {}", refusal.why)));
1943 }
1944 let (ok, out) = gh(
1945 &repo,
1946 &[
1947 "pr".to_owned(),
1948 "close".to_owned(),
1949 url.clone(),
1950 "--comment".to_owned(),
1951 superseded_comment(&base, evidence),
1952 ],
1953 )
1954 .await?;
1955 if !ok {
1956 bail!("gh pr close {url} failed: {out}");
1957 }
1958 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
1959 p.state = "closed".to_owned();
1960 }
1961 Ok(Ok(url))
1962}
1963
1964pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
1968 let (ok, out) = gh(
1969 repo,
1970 &[
1971 "pr".to_owned(),
1972 "edit".to_owned(),
1973 url.to_owned(),
1974 "--title".to_owned(),
1975 title.to_owned(),
1976 ],
1977 )
1978 .await?;
1979 if !ok {
1980 bail!("gh pr edit failed: {out}");
1981 }
1982 Ok(())
1983}
1984
1985pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1998 let Some(winner) = state.winner() else {
1999 return Ok(None);
2000 };
2001 let branch = winner.branch.clone();
2002 let out = gh(
2003 &state.repo,
2004 &[
2005 "pr".to_owned(),
2006 "list".to_owned(),
2007 "--head".to_owned(),
2008 branch.clone(),
2009 "--state".to_owned(),
2010 "merged".to_owned(),
2011 "--json".to_owned(),
2012 "url,number,mergedAt,baseRefName".to_owned(),
2013 ],
2014 )
2015 .await?;
2016 if !out.0 {
2017 bail!("gh pr list --head {branch}: {}", out.1);
2018 }
2019 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2020}
2021
2022pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2034 let out = tokio::process::Command::new("git")
2035 .args(["merge-base", "--is-ancestor", branch, base_branch])
2036 .current_dir(repo)
2037 .quiet()
2038 .stdin(std::process::Stdio::null())
2039 .output()
2040 .await
2041 .context("spawn git merge-base --is-ancestor")?;
2042 Ok(out.status.success())
2043}
2044
2045fn forge_slug(url: &str) -> Option<(String, &str)> {
2053 let rest = url.rsplit("://").next()?;
2054 let (host, path) = rest.split_once('/')?;
2055 if host.is_empty() {
2056 return None;
2057 }
2058 Some((host.to_ascii_lowercase(), path))
2059}
2060
2061pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2069 let (host, path) = forge_slug(url)?;
2070 let mut segments = path.split('/');
2071 let owner = segments.next()?;
2072 let repo = segments.next()?;
2073 let kind = segments.next()?;
2074 if owner.is_empty() || repo.is_empty() || kind != "pull" {
2075 return None;
2076 }
2077 Some(format!("{host}/{owner}/{repo}"))
2078}
2079
2080fn slug_of_repo_url(url: &str) -> Option<String> {
2084 let (host, path) = forge_slug(url)?;
2085 let mut segments = path.split('/');
2086 let owner = segments.next()?;
2087 let repo = segments.next()?;
2088 if owner.is_empty() || repo.is_empty() {
2089 return None;
2090 }
2091 Some(format!("{host}/{owner}/{repo}"))
2092}
2093
2094pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2115 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2116 return Ok(());
2117 }
2118 bail!(
2119 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2120 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2121 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2122 verify by hand before treating this as a false positive)"
2123 );
2124}
2125
2126async fn repo_slug(repo: &Path) -> Result<String> {
2137 let out = gh(
2138 repo,
2139 &[
2140 "repo".to_owned(),
2141 "view".to_owned(),
2142 "--json".to_owned(),
2143 "url".to_owned(),
2144 ],
2145 )
2146 .await?;
2147 if !out.0 {
2148 bail!("gh repo view --json url: {}", out.1);
2149 }
2150 #[derive(Debug, Deserialize)]
2151 struct GhRepo {
2152 url: String,
2153 }
2154 let parsed: GhRepo = serde_json::from_str(&out.1)
2155 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2156 slug_of_repo_url(&parsed.url)
2157 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2158}
2159
2160pub async fn correct_manual_merge(
2208 state: &mut RunState,
2209 url: &str,
2210) -> Result<(RunStatus, RunStatus)> {
2211 let Some(pr_slug) = slug_of_pr_url(url) else {
2212 bail!(
2213 "could not parse an owner/repo out of {url}; refusing to guess which repository \
2214 this pull request belongs to"
2215 );
2216 };
2217 let run_slug = repo_slug(&state.repo).await?;
2218 ensure_same_repo(&run_slug, &pr_slug)?;
2219 correct_merge(state, url).await
2220}
2221
2222pub(crate) async fn correct_confirmed_external_merge(
2233 state: &mut RunState,
2234 url: &str,
2235) -> Result<(RunStatus, RunStatus)> {
2236 correct_merge(state, url).await
2237}
2238
2239fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2241 let Some(pr) = a.pr.as_ref() else {
2242 return false;
2243 };
2244 if !url.is_empty()
2245 && pr
2246 .url
2247 .trim_end_matches('/')
2248 .eq_ignore_ascii_case(url.trim_end_matches('/'))
2249 {
2250 return true;
2251 }
2252 number > 0
2253 && pr.number == number
2254 && match (a.repo.canonicalize(), repo.canonicalize()) {
2255 (Ok(x), Ok(y)) => x == y,
2256 _ => a.repo == repo,
2257 }
2258}
2259
2260fn rewrite_open_prs(
2273 home: &Path,
2274 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2275) -> usize {
2276 let now = Timestamp::now();
2277 let mut changed = 0;
2278 for id in crate::run::list_ids_in(&home.join("runs")) {
2279 let path = home.join("runs").join(&id).join("run.json");
2280 let Ok(body) = std::fs::read_to_string(&path) else {
2281 continue;
2282 };
2283 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2284 continue;
2285 };
2286 if !state.status.done()
2287 || state.pr.as_ref().is_none_or(|p| p.state != "open")
2288 || crate::daemon::is_working_on(home, &id, now)
2289 {
2290 continue;
2291 }
2292 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2293 continue;
2294 };
2295 if let Some(pr) = state.pr.as_mut() {
2296 pr.state = to.as_str().to_owned();
2297 }
2298 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2299 state.event(
2300 "land",
2301 format!("recorded {url} as {}: another run settled it", to.as_str()),
2302 );
2303 match state.save_under(home) {
2304 Ok(()) => changed += 1,
2305 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2306 }
2307 }
2308 changed
2309}
2310
2311pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2316 if to == PrLifecycle::Open {
2317 return;
2318 }
2319 let Some(home) = crate::run::try_home() else {
2320 return;
2321 };
2322 write_pr_state_through_in(&home, run, to);
2323}
2324
2325pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2326 let Some(pr) = run.pr.as_ref() else {
2327 return 0;
2328 };
2329 let (url, number) = (pr.url.clone(), pr.number);
2330 rewrite_open_prs(home, &mut |other| {
2331 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2332 })
2333}
2334
2335pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2338 let now = Timestamp::now();
2339 let mut out = Vec::new();
2340 for id in crate::run::list_ids_in(&home.join("runs")) {
2341 let path = home.join("runs").join(&id).join("run.json");
2342 let Ok(body) = std::fs::read_to_string(&path) else {
2343 continue;
2344 };
2345 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2346 continue;
2347 };
2348 if let Some(pr) = state.pr.as_ref()
2349 && state.status.done()
2350 && pr.state == "open"
2351 && !pr.url.is_empty()
2352 && !crate::daemon::is_working_on(home, &id, now)
2353 {
2354 out.push((id, state.repo.clone(), pr.url.clone()));
2355 }
2356 }
2357 out
2358}
2359
2360pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2363 rewrite_open_prs(home, &mut |s| {
2364 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2365 })
2366}
2367
2368pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2375 let mut known = BTreeMap::new();
2376 let mut failed = 0;
2377 let mut seen = BTreeSet::new();
2378 for (_, repo, url) in stale_open_prs(home) {
2379 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2380 continue;
2381 }
2382 match lifecycle(&repo, &url).await {
2383 Ok(state) => {
2384 known.insert(url, state);
2385 }
2386 Err(e) => {
2387 tracing::warn!("repair pr state of {url}: {e:#}");
2388 failed += 1;
2389 }
2390 }
2391 }
2392 (apply_pr_states(home, &known), failed)
2393}
2394
2395async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2396 match lifecycle(&state.repo, url).await? {
2397 PrLifecycle::Merged => {}
2398 other => bail!(
2399 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2400 other.as_str(),
2401 state.id
2402 ),
2403 }
2404 let before = state.status;
2405 if let Err(e) = land(state, url).await {
2406 state.status = RunStatus::Blocked;
2413 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2414 state.save()?;
2415 return Err(e).context(format!("confirming the merge of {url}"));
2416 }
2417 state.event(
2418 "fold",
2419 "operator recorded this pull request as a manual merge; this run never \
2420 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2421 bump this change might warrant has to be filed by hand",
2422 );
2423 if state.status == RunStatus::Merged {
2426 crate::followup::after_merge(state, url).await;
2427 }
2428 state.save()?;
2429 Ok((before, state.status))
2430}
2431
2432pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2439 let raw: Vec<GhInline> =
2440 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2441 let mut out = Vec::new();
2442 for c in raw {
2443 push_if_outstanding(
2444 &mut out,
2445 ReviewComment {
2446 author: c.user.login,
2447 path: c.path,
2448 line: c.line,
2449 body: c.body,
2450 },
2451 );
2452 }
2453 Ok(out)
2454}
2455
2456fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2462 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2463 return;
2464 }
2465 if comment.path.is_none() && is_noise(&comment.body) {
2466 return;
2467 }
2468 out.push(comment);
2469}
2470
2471pub fn is_noise(body: &str) -> bool {
2489 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2490 return true;
2491 }
2492 let mut content = false;
2493 for line in strip_blocks(body).lines() {
2494 let line = unquote(line);
2495 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2496 continue;
2497 }
2498 content = true;
2499 break;
2500 }
2501 !content
2502}
2503
2504fn strip_blocks(body: &str) -> String {
2506 let mut out = String::with_capacity(body.len());
2507 let mut rest = body;
2508 loop {
2509 let open = ["<!--", "<details>"]
2510 .iter()
2511 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2512 .min_by_key(|(i, _)| *i);
2513 let Some((at, tag)) = open else {
2514 out.push_str(rest);
2515 return out;
2516 };
2517 out.push_str(&rest[..at]);
2518 let after = &rest[at + tag.len()..];
2519 let close = if tag == "<!--" { "-->" } else { "</details>" };
2520 match after.find(close) {
2521 Some(end) => rest = &after[end + close.len()..],
2522 None => return out,
2524 }
2525 }
2526}
2527
2528fn unquote(line: &str) -> &str {
2530 let mut s = line.trim();
2531 while let Some(rest) = s.strip_prefix('>') {
2532 s = rest.trim_start();
2533 }
2534 s.trim()
2535}
2536
2537fn is_checklist(line: &str) -> bool {
2539 let rest = line
2540 .strip_prefix("- ")
2541 .or_else(|| line.strip_prefix("* "))
2542 .unwrap_or("");
2543 let rest = rest.trim_start();
2544 matches!(
2545 rest.get(..3),
2546 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2547 )
2548}
2549
2550fn is_decoration(line: &str) -> bool {
2552 line.starts_with('#')
2553 || line.starts_with("[!")
2554 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2555}
2556
2557fn is_banner(line: &str) -> bool {
2564 let plain = drop_spans(line, "**", "**");
2565 let plain = if plain.contains("](") {
2566 drop_spans(&plain, "[", ")")
2567 } else {
2568 plain
2569 };
2570 !plain.chars().any(char::is_alphanumeric)
2571}
2572
2573fn drop_spans(s: &str, open: &str, close: &str) -> String {
2577 let mut out = String::with_capacity(s.len());
2578 let mut rest = s;
2579 while let Some(at) = rest.find(open) {
2580 out.push_str(&rest[..at]);
2581 let after = &rest[at + open.len()..];
2582 match after.find(close) {
2583 Some(end) => rest = &after[end + close.len()..],
2584 None => return out,
2585 }
2586 }
2587 out.push_str(rest);
2588 out
2589}
2590
2591fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2609 static LOCKS: std::sync::LazyLock<
2610 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2611 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2612 LOCKS
2613 .lock()
2614 .unwrap_or_else(std::sync::PoisonError::into_inner)
2615 .entry(repo.to_path_buf())
2616 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2617 .clone()
2618}
2619
2620fn repo_label(repo: &Path, pr_url: &str) -> String {
2623 let parts: Vec<&str> = pr_url.split('/').collect();
2624 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2625 && at >= 2
2626 && !parts[at - 1].is_empty()
2627 && !parts[at - 2].is_empty()
2628 {
2629 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2630 }
2631 repo.file_name()
2632 .map(|n| n.to_string_lossy().into_owned())
2633 .unwrap_or_default()
2634}
2635
2636fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2640 (pr.checks == Checks::Red).then(|| {
2641 format!(
2642 "Merged {repo_name} PR #{} with red checks: {} ({})",
2643 pr.number,
2644 if pr.failing.is_empty() {
2645 "(none named)".to_owned()
2646 } else {
2647 pr.failing.join(", ")
2648 },
2649 pr.url
2650 )
2651 })
2652}
2653
2654async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2658 let repo_name = repo_label(&state.repo, &pr.url);
2659 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2660 return;
2661 };
2662 if let Some(rec) = state.pr.as_mut() {
2663 rec.red_at_merge = pr.failing.clone();
2664 }
2665 state.event("land", summary.clone());
2666 crate::notices::raise_with(
2669 crate::notices::merged_red(&state.id, &summary),
2670 &state.config.notify,
2671 );
2672}
2673
2674pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2681 land_with(state, pr_url, &GhForge).await
2682}
2683
2684trait Forge {
2688 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2689 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2690 async fn poll(&self);
2691 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
2694 #[allow(clippy::too_many_arguments)]
2695 async fn fix(
2696 &self,
2697 state: &mut RunState,
2698 pr: &PrState,
2699 round: usize,
2700 budget: usize,
2701 reason: &str,
2702 logs: &str,
2703 ) -> Result<Fixed>;
2704}
2705
2706struct GhForge;
2707
2708impl Forge for GhForge {
2709 async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
2710 observe(repo, pr_url).await
2711 }
2712 async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
2713 gh(repo, argv).await
2714 }
2715 async fn poll(&self) {
2716 tokio::time::sleep(POLL).await;
2717 }
2718 async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2719 required_contexts_of(repo, base).await
2720 }
2721 async fn fix(
2722 &self,
2723 state: &mut RunState,
2724 pr: &PrState,
2725 round: usize,
2726 budget: usize,
2727 reason: &str,
2728 logs: &str,
2729 ) -> Result<Fixed> {
2730 fix_round(state, pr, round, budget, reason, logs).await
2731 }
2732}
2733
2734fn encode_path_segment(s: &str) -> String {
2736 let mut out = String::new();
2737 for b in s.bytes() {
2738 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
2739 out.push(b as char);
2740 } else {
2741 let _ = write!(out, "%{b:02X}");
2742 }
2743 }
2744 out
2745}
2746
2747async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
2752 let enc = encode_path_segment(base);
2753 let mut all = BTreeSet::new();
2754 let classic = gh(
2757 repo,
2758 &[
2759 "api".to_owned(),
2760 format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
2761 ],
2762 )
2763 .await
2764 .ok()?;
2765 if classic.0 {
2766 all.extend(parse_classic_required(&classic.1)?);
2767 } else if !classic.1.contains("404") {
2768 return None;
2769 }
2770 let rules = gh(
2771 repo,
2772 &[
2773 "api".to_owned(),
2774 format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
2775 ],
2776 )
2777 .await
2778 .ok()?;
2779 if !rules.0 {
2780 return None;
2781 }
2782 all.extend(parse_ruleset_required(&rules.1)?);
2783 Some(all)
2784}
2785
2786fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
2789 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2790 let mut out = BTreeSet::new();
2791 for c in v.get("contexts")?.as_array()? {
2792 out.insert(c.as_str()?.to_owned());
2793 }
2794 for c in v
2795 .get("checks")
2796 .and_then(|c| c.as_array())
2797 .into_iter()
2798 .flatten()
2799 {
2800 if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
2801 out.insert(name.to_owned());
2802 }
2803 }
2804 Some(out)
2805}
2806
2807fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
2809 let v: serde_json::Value = serde_json::from_str(json).ok()?;
2810 let mut out = BTreeSet::new();
2811 for rule in v.as_array()? {
2812 if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
2813 continue;
2814 }
2815 let checks = rule
2816 .pointer("/parameters/required_status_checks")?
2817 .as_array()?;
2818 for c in checks {
2819 out.insert(c.get("context")?.as_str()?.to_owned());
2820 }
2821 }
2822 Some(out)
2823}
2824
2825fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
2833 awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
2834}
2835
2836fn bound_head<'a>(
2846 seen_head: &'a str,
2847 rollup_head: &str,
2848 awaiting: Option<&str>,
2849) -> Option<&'a str> {
2850 if seen_head.is_empty()
2851 || awaiting_new_head(awaiting, seen_head)
2852 || !rollup_head.eq_ignore_ascii_case(seen_head)
2853 {
2854 return None;
2855 }
2856 Some(seen_head)
2857}
2858
2859#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2861enum Refused {
2862 Pending,
2864 Recheck,
2868 Final,
2871}
2872
2873fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
2876 let Some(after) = after else {
2877 return Refused::Pending;
2879 };
2880 if after.pr.state != PrLifecycle::Open {
2881 return Refused::Final;
2882 }
2883 if !after.head.eq_ignore_ascii_case(observed_head) {
2886 return Refused::Pending;
2887 }
2888 if bound_head(&after.head, &after.rollup_head, None).is_none() {
2890 return Refused::Pending;
2891 }
2892 let state = after.merge_state.to_ascii_uppercase();
2893 if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
2894 || state.is_empty()
2895 || state == "UNKNOWN"
2896 {
2897 return Refused::Pending;
2898 }
2899 if rechecked {
2900 Refused::Final
2901 } else {
2902 Refused::Recheck
2903 }
2904}
2905
2906async fn disarm<F: Forge>(
2911 forge: &F,
2912 state: &mut RunState,
2913 repo: &Path,
2914 number: u64,
2915) -> std::result::Result<(), String> {
2916 let argv = disable_automerge_argv(number);
2917 let out = {
2918 let merge_lock = repo_merge_lock(repo);
2919 let _merge_slot = merge_lock.lock().await;
2920 forge.merge(repo, &argv).await
2921 };
2922 match out {
2923 Ok((true, _)) => {
2924 state.land_armed_head = None;
2925 state.event("land", "auto-merge disabled");
2926 state.save().map_err(|e| format!("{e:#}"))?;
2927 Ok(())
2928 }
2929 Ok((false, msg)) => Err(msg),
2930 Err(e) => Err(format!("{e:#}")),
2931 }
2932}
2933
2934async fn stop_disarmed<F: Forge>(
2938 forge: &F,
2939 state: &mut RunState,
2940 repo: &Path,
2941 pr: &PrState,
2942 why: &str,
2943) -> Result<()> {
2944 if state.land_armed_head.is_none() {
2945 return stop(state, repo, pr, why).await;
2946 }
2947 match disarm(forge, state, repo, pr.number).await {
2948 Ok(()) => stop(state, repo, pr, why).await,
2949 Err(e) => {
2950 let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
2951 stop(state, repo, pr, &why).await
2952 }
2953 }
2954}
2955
2956async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
2957 let repo = state.repo.clone();
2958 let budget = state.config.graph.land_rounds;
2959 let mut round = 0usize;
2960 let mut rebases = 0usize;
2963 let mut waited = Duration::ZERO;
2964 let mut shown: BTreeSet<String> = BTreeSet::new();
2969 let mut awaiting_head: Option<String> = None;
2973 let mut rechecked = false;
2975
2976 state.status = RunStatus::Landing;
2984 state.event("land", format!("watching {pr_url}"));
2985 state.save()?;
2986
2987 let mut resumed_armed = state.land_armed_head.is_some();
2991
2992 loop {
2993 let seen = forge.view(&repo, pr_url).await?;
2994 let mut pr = seen.pr.clone();
2995 pr.review_comments.retain(|c| !shown.contains(&c.body));
2996 state.pr = Some(crate::run::PrRecord {
2997 url: pr.url.clone(),
2998 number: pr.number,
2999 state: pr.state.as_str().to_owned(),
3000 checks: pr.checks.as_str().to_owned(),
3001 round,
3002 rounds: budget,
3003 red_at_merge: Vec::new(),
3004 });
3005 state.save()?;
3006
3007 if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3008 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3013 let why = format!(
3014 "a previous pass may have armed auto-merge and it could not be disabled \
3015 on resume: {e}"
3016 );
3017 stop(state, &repo, &pr, &why).await?;
3018 return Ok(pr);
3019 }
3020 }
3021
3022 if pr.state == PrLifecycle::Open
3027 && !seen.head.is_empty()
3028 && state
3029 .land_armed_head
3030 .as_deref()
3031 .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3032 {
3033 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3034 let why = format!(
3035 "the head moved while auto-merge was armed and it could not be disabled: {e}"
3036 );
3037 stop(state, &repo, &pr, &why).await?;
3038 return Ok(pr);
3039 }
3040 }
3041
3042 if pr.state == PrLifecycle::Open {
3043 if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3044 if waited >= WAIT_CEILING {
3045 let want = awaiting_head.as_deref().unwrap_or_default();
3046 let why = format!(
3047 "the pull request's checks were still not about one readable head after \
3048 {} minutes (expected {}, pull request points at {}, checks are for {}); \
3049 someone may have pushed over it",
3050 WAIT_CEILING.as_secs() / 60,
3051 if want.is_empty() { "any" } else { want },
3052 if seen.head.is_empty() {
3053 "nothing readable"
3054 } else {
3055 &seen.head
3056 },
3057 if seen.rollup_head.is_empty() {
3058 "nothing readable"
3059 } else {
3060 &seen.rollup_head
3061 },
3062 );
3063 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3064 return Ok(pr);
3065 }
3066 waited += POLL;
3067 forge.poll().await;
3068 continue;
3069 }
3070 if awaiting_head.take().is_some() {
3073 waited = Duration::ZERO;
3076 }
3077 }
3078
3079 let step = decide(&pr, round, budget, waited);
3080 let armed_here = state
3085 .land_armed_head
3086 .as_deref()
3087 .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3088 if armed_here && matches!(step, Step::Merge | Step::Wait) {
3089 if waited >= WAIT_CEILING {
3090 let required = if seen.base.is_empty() {
3091 None
3092 } else {
3093 forge.required_contexts(&repo, &seen.base).await
3094 };
3095 let why = format!(
3096 "auto-merge was armed on {} but the pull request did not merge within {} \
3097 minutes ({})",
3098 seen.head,
3099 WAIT_CEILING.as_secs() / 60,
3100 waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3101 );
3102 stop_disarmed(forge, state, &repo, &pr, &why).await?;
3103 return Ok(pr);
3104 }
3105 waited += POLL;
3106 forge.poll().await;
3107 continue;
3108 }
3109 if armed_here && !matches!(step, Step::Done { .. }) {
3110 if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3113 let why = format!("auto-merge could not be disabled: {e}");
3114 stop(state, &repo, &pr, &why).await?;
3115 return Ok(pr);
3116 }
3117 }
3118 match step {
3119 Step::Wait => {
3120 if waited >= WAIT_CEILING {
3121 let why = format!(
3122 "checks were still running after {} minutes",
3123 WAIT_CEILING.as_secs() / 60
3124 );
3125 stop(state, &repo, &pr, &why).await?;
3126 return Ok(pr);
3127 }
3128 waited += POLL;
3129 forge.poll().await;
3130 }
3131 Step::Done { merged } => {
3132 if let Some(armed) = state.land_armed_head.take() {
3137 if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3138 let msg = format!(
3139 "{} merged on {} but the owner approved {armed}; review what landed",
3140 pr.url, seen.head
3141 );
3142 tracing::warn!("{msg}");
3143 state.event("land", msg);
3144 crate::notices::raise_with(
3148 crate::notices::Notice::warn(
3149 &format!("merged-unapproved-head:{}", state.id),
3150 "A pull request merged on a commit the owner did not approve; \
3151 review what landed",
3152 )
3153 .link(crate::notices::Link::Run {
3154 id: state.id.clone(),
3155 }),
3156 &state.config.notify,
3157 );
3158 }
3159 }
3160 state.status = if merged {
3161 RunStatus::Merged
3162 } else {
3163 RunStatus::Ready
3164 };
3165 let detail = if merged {
3166 format!("{} was merged", pr.url)
3167 } else {
3168 format!("{} was closed without merging", pr.url)
3169 };
3170 state.merge = Some(MergeOutcome {
3171 mode: MergeMode::Pr,
3172 ok: merged,
3173 detail: detail.clone(),
3174 empty: false,
3175 });
3176 state.event("land", detail);
3177 state.save()?;
3178 write_pr_state_through(state, pr.state);
3179 return Ok(pr);
3180 }
3181 Step::Merge => {
3182 let subject = merge_subject(
3183 crate::graph::landing_title(state, &seen.title),
3184 &crate::graph::landing_subject_source(state),
3185 );
3186 let contested = contested_to_ask(state);
3193 if state.config.graph.land_approval || contested.is_some() {
3194 match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3195 .await?
3196 {
3197 ApprovalGate::Approved => {}
3198 ApprovalGate::Held => {
3199 stop(
3200 state,
3201 &repo,
3202 &pr,
3203 "the owner did not approve the merge (held or unanswered)",
3204 )
3205 .await?;
3206 return Ok(pr);
3207 }
3208 ApprovalGate::Pending => {
3216 state.parked = true;
3217 state.event(
3218 "land",
3219 "parked awaiting merge approval - resumes once answered",
3220 );
3221 state.save()?;
3222 return Ok(pr);
3223 }
3224 }
3225 }
3226 let observed_head = seen.head.clone();
3237 {
3240 let fresh = forge.view(&repo, pr_url).await.ok();
3241 if !direct_merge_is_safe(
3242 fresh.as_ref(),
3243 &observed_head,
3244 &shown,
3245 round,
3246 budget,
3247 waited,
3248 ) {
3249 if waited >= WAIT_CEILING {
3250 let why = "the pull request did not settle on the approved head \
3251 before it could be merged";
3252 stop(state, &repo, &pr, why).await?;
3253 return Ok(pr);
3254 }
3255 state.event(
3256 "land",
3257 "the pull request changed before merging; looking again",
3258 );
3259 waited += POLL;
3260 forge.poll().await;
3261 continue;
3262 }
3263 }
3264 let argv = merge_argv_at(pr.number, &subject, &observed_head);
3265 let out = {
3266 let merge_lock = repo_merge_lock(&repo);
3267 let _merge_slot = merge_lock.lock().await;
3268 forge.merge(&repo, &argv).await?
3269 };
3270 if out.0 {
3271 let confirmed = forge
3277 .view(&repo, pr_url)
3278 .await
3279 .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3280 if !confirmed {
3281 if waited >= WAIT_CEILING {
3282 let why = "the merge request succeeded but the pull request \
3283 could not be confirmed merged after waiting";
3284 stop(state, &repo, &pr, why).await?;
3285 return Ok(pr);
3286 }
3287 state.event(
3288 "land",
3289 "merge accepted but the pull request is not confirmed merged yet; waiting",
3290 );
3291 state.save()?;
3292 waited += POLL;
3293 forge.poll().await;
3294 continue;
3295 }
3296 pr.state = PrLifecycle::Merged;
3297 state.status = RunStatus::Merged;
3298 state.merge = Some(MergeOutcome {
3299 mode: MergeMode::Pr,
3300 ok: true,
3301 detail: format!("gh {}", argv.join(" ")),
3302 empty: false,
3303 });
3304 if let Some(pr_record) = state.pr.as_mut() {
3309 pr_record.state = pr.state.as_str().to_owned();
3310 }
3311 state.event("land", format!("merged {} as `{subject}`", pr.url));
3312 announce_red_merge(state, &pr).await;
3313 state.save()?;
3314 write_pr_state_through(state, pr.state);
3315 return Ok(pr);
3316 }
3317 let after_seen = forge.view(&repo, pr_url).await.ok();
3318 let after = after_seen.as_ref().map(|s| s.pr.state);
3319 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3320 pr.state = PrLifecycle::Merged;
3321 state.status = RunStatus::Merged;
3322 state.merge = Some(outcome);
3323 if let Some(pr_record) = state.pr.as_mut() {
3324 pr_record.state = pr.state.as_str().to_owned();
3325 }
3326 state.event("land", format!("merged {} as `{subject}`", pr.url));
3327 announce_red_merge(state, &pr).await;
3328 state.save()?;
3329 write_pr_state_through(state, pr.state);
3330 return Ok(pr);
3331 }
3332 let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3333 match verdict {
3334 Refused::Final => {
3335 let merge_state = after_seen
3336 .as_ref()
3337 .map(|s| s.merge_state.as_str())
3338 .filter(|m| !m.is_empty())
3339 .unwrap_or("unknown");
3340 let why = format!(
3344 "the merge was refused: {} (merge state: {merge_state})",
3345 out.1
3346 );
3347 stop(state, &repo, &pr, &why).await?;
3348 return Ok(pr);
3349 }
3350 verdict => {
3351 if waited >= WAIT_CEILING {
3356 let why = format!(
3357 "the merge was still refused after {} minutes: {}",
3358 WAIT_CEILING.as_secs() / 60,
3359 out.1
3360 );
3361 stop(state, &repo, &pr, &why).await?;
3362 return Ok(pr);
3363 }
3364 if verdict == Refused::Recheck {
3365 rechecked = true;
3366 }
3367 state.event(
3368 "land",
3369 "merge refused while the branch policy is not satisfied yet; waiting",
3370 );
3371 state.save()?;
3372 waited += POLL;
3373 forge.poll().await;
3374 }
3375 }
3376 }
3377 Step::Rebase => {
3378 if rebases >= budget {
3384 let why = format!(
3385 "the base moved under this branch {budget} time(s) and it still does \
3386 not merge; rebasing again would only race it"
3387 );
3388 stop(state, &repo, &pr, &why).await?;
3389 return Ok(pr);
3390 }
3391 rebases += 1;
3392 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3393 stop(
3394 state,
3395 &repo,
3396 &pr,
3397 "the pull request conflicts and this run has no winning branch to rebase",
3398 )
3399 .await?;
3400 return Ok(pr);
3401 };
3402 let base = state.base_branch.clone();
3403 state.event(
3404 "land",
3405 format!("{} no longer merges; rebasing onto {base}", pr.url),
3406 );
3407 state.save()?;
3408
3409 git::fetch(&repo, "origin", &base).await.ok();
3413 let scratch = state.dir().join("rebase");
3414 let onto = format!("origin/{base}");
3415 let rebased =
3416 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3417 Ok(crate::rebase::Rebased::Applied) => Ok(None),
3418 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3419 Err(e) => Err(e),
3420 };
3421 match rebased {
3422 Ok(None) => {
3423 let pushed = {
3424 let merge_lock = repo_merge_lock(&repo);
3425 let _merge_slot = merge_lock.lock().await;
3426 git::push_rewritten(&repo, "origin", &branch).await?
3427 };
3428 if !pushed.ok() {
3429 let why = format!(
3430 "rebased {branch} but could not push it: {}",
3431 pushed.stderr.trim()
3432 );
3433 stop(state, &repo, &pr, &why).await?;
3434 return Ok(pr);
3435 }
3436 let head =
3439 match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3440 Ok(head) => head,
3441 Err(e) => {
3442 let why = format!(
3443 "rebased and pushed {branch} but could not read the pushed \
3444 commit: {e:#}"
3445 );
3446 stop(state, &repo, &pr, &why).await?;
3447 return Ok(pr);
3448 }
3449 };
3450 crate::graph::refresh_reviewed_commits(state, &branch).await;
3451 awaiting_head = Some(head);
3452 rechecked = false;
3453 state.event("land", format!("rebased {branch} onto {base}"));
3454 state.save()?;
3455 waited = Duration::ZERO;
3458 tokio::time::sleep(POLL).await;
3459 }
3460 Ok(Some(conflict)) => {
3463 let why = format!(
3464 "{} conflicts with {base} and the rebase did not apply: {}",
3465 pr.url,
3466 conflict.chars().take(600).collect::<String>()
3467 );
3468 stop(state, &repo, &pr, &why).await?;
3469 return Ok(pr);
3470 }
3471 Err(e) => {
3472 let why = format!("could not rebase {branch} onto {base}: {e:#}");
3473 stop(state, &repo, &pr, &why).await?;
3474 return Ok(pr);
3475 }
3476 }
3477 }
3478 Step::GiveUp { reason } => {
3479 stop(state, &repo, &pr, &reason).await?;
3480 return Ok(pr);
3481 }
3482 Step::Fix { reason } => {
3483 round += 1;
3484 waited = Duration::ZERO;
3485 for c in &pr.review_comments {
3486 shown.insert(c.body.clone());
3487 }
3488 state.event("land", format!("round {round}: {reason}"));
3489 state.save()?;
3490
3491 let logs = failing_logs(&repo, &seen.failing_urls).await;
3492 let was_red = pr.checks == Checks::Red;
3493 match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3494 Fixed::Committed { head } => {
3495 awaiting_head = Some(head);
3498 rechecked = false;
3499 waited = Duration::ZERO;
3500 forge.poll().await;
3501 }
3502 Fixed::Declined if was_red => {
3503 let why = format!(
3504 "the fixer produced no commit while {} check(s) were failing \
3505 ({}); stopping instead of looping on an unchanged tree",
3506 pr.failing.len(),
3507 pr.failing.join(", ")
3508 );
3509 stop(state, &repo, &pr, &why).await?;
3510 return Ok(pr);
3511 }
3512 Fixed::Declined => state.event(
3517 "land",
3518 format!("round {round}: fixer declined the comments, nothing committed"),
3519 ),
3520 Fixed::Failed(why) => {
3521 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3522 return Ok(pr);
3523 }
3524 }
3525 state.save()?;
3526 }
3527 }
3528 }
3529}
3530
3531#[derive(Clone)]
3535struct Seen {
3536 pr: PrState,
3537 title: String,
3538 failing_urls: Vec<(String, String)>,
3539 head: String,
3541 rollup_head: String,
3544 merge_state: String,
3547 contexts: Vec<CheckInfo>,
3549 base: String,
3551}
3552
3553#[derive(Clone)]
3555struct CheckInfo {
3556 label: String,
3557 verdict: Verdict,
3558 required: Option<bool>,
3559}
3560
3561async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
3565 let view = gh(
3566 repo,
3567 &[
3568 "pr".to_owned(),
3569 "view".to_owned(),
3570 pr_url.to_owned(),
3571 "--json".to_owned(),
3572 "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
3573 .to_owned(),
3574 ],
3575 )
3576 .await?;
3577 if !view.0 {
3578 bail!("gh pr view {pr_url}: {}", view.1);
3579 }
3580 let number = parse_pr(&view.1)?.number;
3581 let node = last_commit_node(repo, number).await;
3582 let mut seen = seen_from(&view.1, node.as_deref())?;
3583
3584 let inline = gh(
3585 repo,
3586 &[
3587 "api".to_owned(),
3588 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
3589 ],
3590 )
3591 .await?;
3592 if inline.0 {
3593 match parse_inline_comments(&inline.1) {
3594 Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
3595 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
3598 }
3599 } else {
3600 tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
3601 }
3602 Ok(seen)
3603}
3604
3605fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
3616 let mut pr = parse_pr(view_json)?;
3617 let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
3618
3619 let mut rollup_head = String::new();
3620 let mut failing_urls = Vec::new();
3621 let mut contexts = Vec::new();
3622 let mut checks = Checks::Unknown;
3623 let mut failing = Vec::new();
3624 if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
3625 (checks, failing) = rollup_verdict(&rollup);
3626 failing_urls = rollup
3627 .iter()
3628 .filter(|c| c.verdict() == Verdict::Fail)
3629 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
3630 .collect();
3631 contexts = rollup
3632 .iter()
3633 .map(|c| CheckInfo {
3634 label: c.label(),
3635 verdict: c.verdict(),
3636 required: c.is_required,
3637 })
3638 .collect();
3639 rollup_head = oid;
3640 }
3641 pr.checks = checks;
3642 pr.failing = failing;
3643
3644 Ok(Seen {
3645 pr,
3646 title: raw.title,
3647 failing_urls,
3648 head: raw.head_ref_oid,
3649 rollup_head,
3650 merge_state: raw.merge_state_status,
3651 contexts,
3652 base: raw.base_ref_name,
3653 })
3654}
3655
3656fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
3659 let v: serde_json::Value = serde_json::from_str(json).ok()?;
3660 if v.get("errors").is_some_and(|e| !e.is_null()) {
3661 return None;
3662 }
3663 let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
3664 let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
3665 let contexts = commit.pointer("/statusCheckRollup/contexts");
3666 let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
3667 return Some((oid.to_owned(), Vec::new()));
3669 };
3670 if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
3671 return None;
3672 }
3673 let nodes = contexts.get("nodes")?.as_array()?;
3674 let rollup = nodes
3675 .iter()
3676 .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
3677 .collect::<Result<Vec<_>, _>>()
3678 .ok()?;
3679 Some((oid.to_owned(), rollup))
3680}
3681
3682async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
3685 let out = gh(
3686 repo,
3687 &[
3688 "api".to_owned(),
3689 "graphql".to_owned(),
3690 "-F".to_owned(),
3691 "owner={owner}".to_owned(),
3692 "-F".to_owned(),
3693 "repo={repo}".to_owned(),
3694 "-F".to_owned(),
3695 format!("number={number}"),
3696 "-f".to_owned(),
3697 "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
3698 name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
3699 statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
3700 ... on CheckRun{name status conclusion detailsUrl \
3701 isRequired(pullRequestNumber:$number)} \
3702 ... on StatusContext{context state targetUrl \
3703 isRequired(pullRequestNumber:$number)}}}}}}}}}}"
3704 .to_owned(),
3705 ],
3706 )
3707 .await
3708 .ok()?;
3709 out.0.then_some(out.1)
3710}
3711
3712#[doc(hidden)]
3714#[derive(Debug, PartialEq)]
3715pub enum Fixed {
3716 Committed {
3718 head: String,
3720 },
3721 Declined,
3723 Failed(String),
3725}
3726
3727#[doc(hidden)]
3733pub async fn fix_round(
3734 state: &mut RunState,
3735 pr: &PrState,
3736 round: usize,
3737 budget: usize,
3738 reason: &str,
3739 logs: &str,
3740) -> Result<Fixed> {
3741 let winner = state
3742 .winner()
3743 .cloned()
3744 .context("landing needs a winning candidate; none is recorded on this run")?;
3745 let roles = state
3746 .config
3747 .resolve_roles()
3748 .context("resolve the roster for the fix round")?;
3749 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
3753 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3754 _ => (
3755 state
3756 .config
3757 .agent(&winner.agent)
3758 .cloned()
3759 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
3760 format!("impl-{}", winner.label),
3761 ),
3762 };
3763
3764 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
3765 let mut seat = seat_of(state, &seat_key, &spec.id);
3766 let artifacts = agent::artifacts_dir(&state.dir());
3767 let prompt = if state.config.cache_dir().is_some() {
3768 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
3769 } else {
3770 prompt
3771 };
3772 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
3776 let out = agent::invoke(
3777 &spec,
3778 &mut seat,
3779 &Invocation {
3780 cwd: &winner.worktree,
3781 prompt: &prompt,
3782 timeout: Duration::from_secs(state.config.graph.timeout_fix),
3783 allow_write: true,
3784 sessions: state.config.graph.sessions,
3785 artifacts: &artifacts,
3786 stem: &format!("land-{round}"),
3787 run: &state.id,
3788 node: "land",
3789 cache_dir: state.config.cache_dir().as_deref(),
3790 attachments: &[],
3791 writable: &[],
3792 },
3793 )
3794 .await;
3795 state.seats.insert(seat.key.clone(), seat);
3796
3797 match out {
3798 Ok(o) if o.quota_exhausted() => {
3799 return Ok(Fixed::Failed(
3800 "rate limited (quota); the fixer could not run".to_owned(),
3801 ));
3802 }
3803 Ok(o) if !o.usable() => {
3804 return Ok(Fixed::Failed(format!(
3805 "the fixer produced nothing usable (exit {:?}, timed out: {})",
3806 o.exit_code, o.timed_out
3807 )));
3808 }
3809 Ok(_) => {}
3810 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
3811 }
3812
3813 if let Ok(r) = git::rescue_commit(
3816 &winner.worktree,
3817 &format!("magi: land round {round} fixes (uncommitted work)"),
3818 )
3819 .await
3820 {
3821 state.note_withheld("land", &r.withheld);
3822 }
3823 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
3824 if after == before {
3825 return Ok(Fixed::Declined);
3826 }
3827
3828 let remote = state.config.merge.remote.clone();
3829 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
3830 if !push.ok() {
3831 return Ok(Fixed::Failed(format!(
3832 "pushing {} to {remote} failed: {}",
3833 winner.branch, push.stderr
3834 )));
3835 }
3836 state.event(
3837 "land",
3838 format!("round {round}: pushed a fix to {}", winner.branch),
3839 );
3840 Ok(Fixed::Committed { head: after })
3841}
3842
3843pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
3845 if let Some(existing) = state.seats.get(key)
3846 && existing.agent == agent
3847 {
3848 return existing.clone();
3849 }
3850 let fresh = SeatState::new(key, agent, state.seed);
3851 state.seats.insert(key.to_owned(), fresh.clone());
3852 fresh
3853}
3854
3855fn fix_prompt(
3857 state: &RunState,
3858 pr: &PrState,
3859 round: usize,
3860 budget: usize,
3861 reason: &str,
3862 logs: &str,
3863) -> String {
3864 let mut s = format!(
3865 "Your patch is open as a pull request and it is not landing. Land round \
3866 {round} of {budget}.\n\n\
3867 Pull request: {}\n\n\
3868 What is holding it: {reason}\n\n\
3869 # The task\n\n{}\n",
3870 pr.url, state.instruction
3871 );
3872
3873 if pr.failing.is_empty() {
3874 s.push_str("\n# Failing checks\n\n(none)\n");
3875 } else {
3876 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
3877 if logs.trim().is_empty() {
3878 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
3879 } else {
3880 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
3881 }
3882 }
3883
3884 if pr.review_comments.is_empty() {
3885 s.push_str("\n# Review comments\n\n(none)\n");
3886 } else {
3887 s.push_str("\n# Review comments\n");
3888 for c in &pr.review_comments {
3889 let where_ = match (&c.path, c.line) {
3890 (Some(p), Some(l)) => format!(" ({p}:{l})"),
3891 (Some(p), None) => format!(" ({p})"),
3892 _ => String::new(),
3893 };
3894 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
3895 }
3896 }
3897
3898 s.push_str(
3899 "\n# Rules\n\n\
3900 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
3901 failing test; do not silence a lint with an allow attribute; do not \
3902 stretch a timeout to hide a race. If the check is right, the code is \
3903 wrong.\n\
3904 2. Change nothing the checks and the comments did not raise. A \
3905 drive-by refactor turns a one-line fix into a pull request that \
3906 needs reviewing again.\n\
3907 3. If a comment is wrong, say so with a checkable argument and change \
3908 nothing for it. A declined comment with a reason is a correct \
3909 outcome; a change made to appease a reviewer is not.\n\
3910 4. Commit in this worktree. magi pushes to the pull request's branch \
3911 for you; do not push, merge, or close anything yourself.\n\
3912 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
3913 # Output\n\n\
3914 Say what you changed and why, and what you declined and why.",
3915 );
3916
3917 let language = &state.config.graph.language;
3918 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
3919 let _ = write!(s, "\n\nWrite all prose in {language}.");
3920 }
3921 s.push_str(&crate::prompt::github_english(language));
3923 if let Some(overlay) = state.config.prompts.overlay("fix") {
3924 let _ = write!(s, "\n\n{overlay}");
3925 }
3926 s
3927}
3928
3929async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
3932 let mut out = String::new();
3933 for (name, url) in failing.iter().take(MAX_LOGS) {
3934 let args = match (job_of(url), run_of(url)) {
3935 (Some(job), _) => vec![
3936 "run".to_owned(),
3937 "view".to_owned(),
3938 "--log-failed".to_owned(),
3939 "--job".to_owned(),
3940 job,
3941 ],
3942 (None, Some(run)) => vec![
3943 "run".to_owned(),
3944 "view".to_owned(),
3945 run,
3946 "--log-failed".to_owned(),
3947 ],
3948 (None, None) => continue,
3950 };
3951 let (ok, body) = match gh(repo, &args).await {
3952 Ok(v) => v,
3953 Err(e) => (false, format!("{e:#}")),
3954 };
3955 if !ok && body.trim().is_empty() {
3956 continue;
3957 }
3958 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
3959 }
3960 out
3961}
3962
3963fn job_of(details_url: &str) -> Option<String> {
3966 let after = details_url.split("/job/").nth(1)?;
3967 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3968 (!id.is_empty()).then_some(id)
3969}
3970
3971pub(crate) fn run_of(details_url: &str) -> Option<String> {
3973 let after = details_url.split("/actions/runs/").nth(1)?;
3974 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3975 (!id.is_empty()).then_some(id)
3976}
3977
3978fn stop_comment(run_id: &str, why: &str) -> String {
3982 format!(
3983 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
3984 The branch is untouched and the run is `{run_id}`. Nothing was merged."
3985 )
3986}
3987
3988async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
3993 let body = stop_comment(&state.id, why);
3994 let posted = gh(
3995 repo,
3996 &[
3997 "pr".to_owned(),
3998 "comment".to_owned(),
3999 pr.number.to_string(),
4000 "--body".to_owned(),
4001 body,
4002 ],
4003 )
4004 .await;
4005 match posted {
4006 Ok((true, _)) => {}
4007 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4008 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4009 }
4010 state.status = RunStatus::Blocked;
4011 state.merge = Some(MergeOutcome {
4012 mode: MergeMode::Pr,
4013 ok: false,
4014 detail: why.to_owned(),
4015 empty: false,
4016 });
4017 state.event("land", format!("stopped: {why}"));
4018 state.save()?;
4019 Ok(())
4020}
4021
4022pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4038 let out = tokio::process::Command::new("gh")
4039 .args(args)
4040 .current_dir(cwd)
4041 .env_remove("GH_REPO")
4042 .quiet()
4043 .stdin(std::process::Stdio::null())
4044 .output()
4045 .await
4046 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4047 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4048 let err = String::from_utf8_lossy(&out.stderr);
4049 if body.trim().is_empty() {
4050 body = err.into_owned();
4051 } else if !err.trim().is_empty() {
4052 body.push_str(&err);
4053 }
4054 Ok((out.status.success(), body.trim().to_owned()))
4055}
4056
4057#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4059pub(crate) enum Verdict {
4060 Pass,
4061 Fail,
4062 Pending,
4063 Unknown,
4064}
4065
4066#[derive(Debug, Deserialize)]
4067#[serde(rename_all = "camelCase")]
4068struct GhPr {
4069 #[serde(default)]
4070 url: String,
4071 #[serde(default)]
4072 number: u64,
4073 #[serde(default)]
4074 state: String,
4075 #[serde(default)]
4076 title: String,
4077 #[serde(default)]
4078 status_check_rollup: Vec<GhCheck>,
4079 #[serde(default)]
4086 merge_state_status: String,
4087 #[serde(default)]
4091 head_ref_oid: String,
4092 #[serde(default)]
4093 base_ref_name: String,
4094 #[serde(default)]
4095 reviews: Vec<GhReview>,
4096 #[serde(default)]
4097 comments: Vec<GhComment>,
4098}
4099
4100#[derive(Debug, Deserialize)]
4105#[serde(rename_all = "camelCase")]
4106struct GhCheck {
4107 #[serde(default)]
4108 name: Option<String>,
4109 #[serde(default)]
4110 context: Option<String>,
4111 #[serde(default)]
4112 status: Option<String>,
4113 #[serde(default)]
4114 conclusion: Option<String>,
4115 #[serde(default)]
4116 state: Option<String>,
4117 #[serde(default)]
4118 details_url: Option<String>,
4119 #[serde(default)]
4120 target_url: Option<String>,
4121 #[serde(default)]
4124 is_required: Option<bool>,
4125}
4126
4127impl GhCheck {
4128 fn label(&self) -> String {
4130 self.name
4131 .clone()
4132 .or_else(|| self.context.clone())
4133 .unwrap_or_else(|| "(unnamed check)".to_owned())
4134 }
4135
4136 fn url(&self) -> Option<&str> {
4138 self.details_url
4139 .as_deref()
4140 .or(self.target_url.as_deref())
4141 .filter(|u| !u.is_empty())
4142 }
4143
4144 fn verdict(&self) -> Verdict {
4152 if let Some(status) = self.status.as_deref() {
4153 if !status.eq_ignore_ascii_case("COMPLETED") {
4154 return Verdict::Pending;
4155 }
4156 }
4157 let outcome = self
4158 .conclusion
4159 .as_deref()
4160 .or(self.state.as_deref())
4161 .unwrap_or("");
4162 match outcome.to_ascii_uppercase().as_str() {
4163 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4164 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4165 | "ACTION_REQUIRED" => Verdict::Fail,
4166 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4167 Verdict::Pending
4168 }
4169 _ => Verdict::Unknown,
4170 }
4171 }
4172}
4173
4174#[derive(Debug, Deserialize)]
4175struct GhAuthor {
4176 #[serde(default)]
4177 login: String,
4178}
4179
4180#[derive(Debug, Deserialize)]
4181struct GhReview {
4182 #[serde(default)]
4183 author: GhAuthor,
4184 #[serde(default)]
4185 body: String,
4186}
4187
4188#[derive(Debug, Deserialize)]
4189struct GhComment {
4190 #[serde(default)]
4191 author: GhAuthor,
4192 #[serde(default)]
4193 body: String,
4194}
4195
4196#[derive(Debug, Deserialize)]
4197struct GhUser {
4198 #[serde(default)]
4199 login: String,
4200}
4201
4202#[derive(Debug, Deserialize)]
4203struct GhInline {
4204 #[serde(default)]
4205 user: GhUser,
4206 #[serde(default)]
4207 path: Option<String>,
4208 #[serde(default)]
4209 line: Option<u64>,
4210 #[serde(default)]
4211 body: String,
4212}
4213
4214impl Default for GhAuthor {
4215 fn default() -> Self {
4216 Self {
4217 login: "(unknown)".to_owned(),
4218 }
4219 }
4220}
4221
4222impl Default for GhUser {
4223 fn default() -> Self {
4224 Self {
4225 login: "(unknown)".to_owned(),
4226 }
4227 }
4228}
4229
4230#[cfg(test)]
4231mod tests {
4232 use super::*;
4233 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4234
4235 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4236 format!(
4237 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4238 )
4239 }
4240
4241 #[test]
4242 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4243 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4244 assert_eq!(
4245 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4246 Ok(())
4247 );
4248 for (json, why) in [
4249 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4250 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4251 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4252 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4253 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4254 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4255 ] {
4256 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4257 .unwrap_err()
4258 .why;
4259 assert!(err.contains(why), "{json}: {err}");
4260 }
4261 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4263 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4264 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4265 assert!(
4266 !closable(
4267 &head_json("x", "main", "OPEN", false),
4268 "magi/27b2/A",
4269 "main",
4270 &[]
4271 )
4272 .unwrap_err()
4273 .retry
4274 );
4275 assert!(is_forge_url("https://github.com/o/r.git"));
4276 assert!(is_forge_url("git@github.com:o/r.git"));
4277 assert!(!is_forge_url("/tmp/origin.git"));
4278 assert!(!is_forge_url("C:\\work\\origin.git"));
4279 assert!(!is_forge_url("file:///tmp/origin.git"));
4280 assert!(forge_unavailable(
4281 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4282 ));
4283 assert!(!forge_unavailable(
4284 "gh pr list failed: error connecting to api.github.com"
4285 ));
4286 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4287 assert!(
4289 closable(
4290 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4291 "b",
4292 "main",
4293 &["aaa".to_owned()]
4294 )
4295 .is_err()
4296 );
4297 }
4298
4299 #[test]
4300 fn the_close_comment_names_the_commit_on_the_base() {
4301 let e = crate::already::Evidence {
4302 proof: crate::already::Proof::PatchId,
4303 tip: "1234567890".to_owned(),
4304 commits: vec!["0e368de0000".to_owned()],
4305 };
4306 let c = superseded_comment("main", &e);
4307 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4308 }
4309
4310 const GREEN_OPEN: &str = r####"{
4312 "url": "https://github.com/yukimemi/magi/pull/10",
4313 "number": 10,
4314 "state": "OPEN",
4315 "mergeStateStatus": "CLEAN",
4316 "statusCheckRollup": [
4317 {
4318 "__typename": "CheckRun",
4319 "conclusion": "SKIPPED",
4320 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4321 "name": "review",
4322 "status": "COMPLETED",
4323 "workflowName": "claude-review"
4324 },
4325 {
4326 "__typename": "CheckRun",
4327 "conclusion": "SUCCESS",
4328 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4329 "name": "check (ubuntu-latest)",
4330 "status": "COMPLETED",
4331 "workflowName": "CI"
4332 },
4333 {
4334 "__typename": "CheckRun",
4335 "conclusion": "SUCCESS",
4336 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4337 "name": "rustfmt",
4338 "status": "COMPLETED",
4339 "workflowName": "CI"
4340 },
4341 {
4342 "__typename": "StatusContext",
4343 "context": "CodeRabbit",
4344 "state": "SUCCESS",
4345 "targetUrl": ""
4346 }
4347 ],
4348 "reviews": [],
4349 "comments": [
4350 {
4351 "author": {
4352 "login": "coderabbitai"
4353 },
4354 "authorAssociation": "NONE",
4355 "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"
4356 }
4357 ]
4358}"####;
4359
4360 const RED_OPEN: &str = r####"{
4362 "url": "https://github.com/yukimemi/magi/pull/9",
4363 "number": 9,
4364 "state": "OPEN",
4365 "mergeStateStatus": "UNSTABLE",
4366 "statusCheckRollup": [
4367 {
4368 "__typename": "CheckRun",
4369 "conclusion": "SUCCESS",
4370 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4371 "name": "check (ubuntu-latest)",
4372 "status": "COMPLETED",
4373 "workflowName": "CI"
4374 },
4375 {
4376 "__typename": "CheckRun",
4377 "conclusion": "SUCCESS",
4378 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4379 "name": "rustfmt",
4380 "status": "COMPLETED",
4381 "workflowName": "CI"
4382 },
4383 {
4384 "__typename": "CheckRun",
4385 "conclusion": "FAILURE",
4386 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4387 "name": "editorconfig",
4388 "status": "COMPLETED",
4389 "workflowName": "CI"
4390 },
4391 {
4392 "__typename": "StatusContext",
4393 "context": "CodeRabbit",
4394 "state": "SUCCESS",
4395 "targetUrl": ""
4396 }
4397 ],
4398 "reviews": [],
4399 "comments": [
4400 {
4401 "author": {
4402 "login": "coderabbitai"
4403 },
4404 "authorAssociation": "NONE",
4405 "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"
4406 }
4407 ]
4408}"####;
4409
4410 const PENDING_OPEN: &str = r####"{
4412 "url": "https://github.com/yukimemi/magi/pull/9",
4413 "number": 9,
4414 "state": "OPEN",
4415 "statusCheckRollup": [
4416 {
4417 "__typename": "CheckRun",
4418 "conclusion": "SUCCESS",
4419 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4420 "name": "check (ubuntu-latest)",
4421 "status": "COMPLETED",
4422 "workflowName": "CI"
4423 },
4424 {
4425 "__typename": "CheckRun",
4426 "conclusion": "SUCCESS",
4427 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4428 "name": "rustfmt",
4429 "status": "COMPLETED",
4430 "workflowName": "CI"
4431 },
4432 {
4433 "__typename": "CheckRun",
4434 "conclusion": null,
4435 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4436 "name": "editorconfig",
4437 "status": "IN_PROGRESS",
4438 "workflowName": "CI"
4439 },
4440 {
4441 "__typename": "StatusContext",
4442 "context": "CodeRabbit",
4443 "state": "SUCCESS",
4444 "targetUrl": ""
4445 }
4446 ],
4447 "reviews": [],
4448 "comments": []
4449}"####;
4450
4451 const MERGED: &str = r####"{
4453 "url": "https://github.com/yukimemi/magi/pull/16",
4454 "number": 16,
4455 "state": "MERGED",
4456 "statusCheckRollup": [
4457 {
4458 "__typename": "CheckRun",
4459 "conclusion": "SUCCESS",
4460 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
4461 "name": "check (ubuntu-latest)",
4462 "status": "COMPLETED",
4463 "workflowName": "CI"
4464 },
4465 {
4466 "__typename": "CheckRun",
4467 "conclusion": "SUCCESS",
4468 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
4469 "name": "review",
4470 "status": "COMPLETED",
4471 "workflowName": "claude-review"
4472 }
4473 ],
4474 "reviews": [],
4475 "comments": []
4476}"####;
4477
4478 const REVIEWED_OPEN: &str = r####"{
4480 "url": "https://github.com/yukimemi/magi/pull/12",
4481 "number": 12,
4482 "state": "OPEN",
4483 "statusCheckRollup": [
4484 {
4485 "__typename": "CheckRun",
4486 "conclusion": "SUCCESS",
4487 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
4488 "name": "check (ubuntu-latest)",
4489 "status": "COMPLETED",
4490 "workflowName": "CI"
4491 },
4492 {
4493 "__typename": "CheckRun",
4494 "conclusion": "SUCCESS",
4495 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
4496 "name": "review",
4497 "status": "COMPLETED",
4498 "workflowName": "claude-review"
4499 }
4500 ],
4501 "reviews": [
4502 {
4503 "author": {
4504 "login": "claude"
4505 },
4506 "state": "COMMENTED",
4507 "body": ""
4508 }
4509 ],
4510 "comments": [
4511 {
4512 "author": {
4513 "login": "coderabbitai"
4514 },
4515 "authorAssociation": "NONE",
4516 "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"
4517 },
4518 {
4519 "author": {
4520 "login": "claude"
4521 },
4522 "authorAssociation": "NONE",
4523 "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"
4524 }
4525 ]
4526}"####;
4527
4528 const INLINE: &str = r####"[
4530 {
4531 "user": {
4532 "login": "claude[bot]"
4533 },
4534 "path": "src/graph.rs",
4535 "line": 231,
4536 "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"
4537 }
4538]"####;
4539
4540 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
4542<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
4543
4544> [!IMPORTANT]
4545> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
4546>
4547> This repository does not receive automatic reviews because it has fewer than 10 stars.
4548>
4549> <details>
4550> <summary>⚙️ Run configuration</summary>
4551>
4552> **Configuration used**: defaults
4553>
4554> **Review profile**: CHILL
4555>
4556> **Plan**: Team
4557>
4558> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
4559>
4560> </details>
4561
4562<!-- end of auto-generated comment: skip review by coderabbit.ai -->
4563
4564<!-- tips_start -->
4565
4566---
4567
4568Thanks 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.
4569
4570<details>
4571<summary>❤️ Share</summary>
4572
4573- [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"####;
4574
4575 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
4577
4578---
4579### Reviewing PR #16
4580
4581- [x] Read AGENTS.md conventions
4582- [x] Review `src/daemon.rs` changes
4583- [x] Review `src/main.rs` changes (new `doctor` reporting)
4584- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
4585- [x] Check test coverage for new behavior
4586- [x] Run verification commands (blocked — see note)
4587- [x] Post findings"####;
4588
4589 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
4591
4592---
4593### Review: `magi review <branch>` — cheap-half-only graph
4594
4595Read 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.
4596
4597**Correctness**
4598
4599- 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"####;
4600
4601 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
4602 PrState {
4603 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
4604 number: 16,
4605 state: PrLifecycle::Open,
4606 checks,
4607 blocking: if matches!(checks, Checks::Red) {
4611 Blocking::Yes
4612 } else {
4613 Blocking::No
4614 },
4615 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
4616 review_comments: (0..comments)
4617 .map(|i| ReviewComment {
4618 author: "coderabbitai".to_owned(),
4619 path: Some("src/graph.rs".to_owned()),
4620 line: Some(231),
4621 body: format!("finding {i}"),
4622 })
4623 .collect(),
4624 }
4625 }
4626
4627 #[test]
4628 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
4629 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
4630 assert_eq!(state.number, 10);
4631 assert_eq!(state.state, PrLifecycle::Open);
4632 assert_eq!(state.checks, Checks::Green);
4633 assert!(state.failing.is_empty());
4634 assert!(
4635 state.review_comments.is_empty(),
4636 "the only comment is CodeRabbit's trigger notice: {:?}",
4637 state.review_comments
4638 );
4639 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
4640 }
4641
4642 #[test]
4643 fn a_failing_check_parses_as_red_and_is_named() {
4644 let state = parse_pr(RED_OPEN).expect("red fixture parses");
4645 assert_eq!(state.checks, Checks::Red);
4646 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
4647 let mut blocking = state.clone();
4654 blocking.blocking = Blocking::Yes;
4655 match decide(&blocking, 0, 4, Duration::ZERO) {
4656 Step::Fix { reason } => {
4657 assert!(reason.contains("editorconfig"), "reason: {reason}");
4658 assert!(reason.contains("failing"), "reason: {reason}");
4659 }
4660 other => panic!("expected a fix round, got {other:?}"),
4661 }
4662 }
4663
4664 #[test]
4665 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
4666 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
4667 assert_eq!(state.checks, Checks::Pending);
4668 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
4669 }
4670
4671 #[test]
4672 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
4673 let state = parse_pr(MERGED).expect("merged fixture parses");
4674 assert_eq!(state.state, PrLifecycle::Merged);
4675 assert_eq!(
4676 decide(&state, 0, 4, Duration::ZERO),
4677 Step::Done { merged: true }
4678 );
4679 }
4680
4681 #[test]
4682 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
4683 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
4684 assert_eq!(state.checks, Checks::Green);
4685 let authors: Vec<&str> = state
4686 .review_comments
4687 .iter()
4688 .map(|c| c.author.as_str())
4689 .collect();
4690 assert_eq!(
4691 authors,
4692 vec!["claude"],
4693 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
4694 );
4695 match decide(&state, 0, 4, Duration::ZERO) {
4696 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
4697 other => panic!("expected a fix round, got {other:?}"),
4698 }
4699 }
4700
4701 #[test]
4702 fn inline_review_comments_keep_their_file_and_line() {
4703 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
4704 assert_eq!(comments.len(), 1);
4705 assert_eq!(comments[0].author, "claude[bot]");
4706 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
4707 assert_eq!(comments[0].line, Some(231));
4708 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
4709 }
4710
4711 #[test]
4712 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
4713 assert!(
4714 is_noise(CODERABBIT_TRIGGER),
4715 "CodeRabbit's trigger notice declares itself not a review"
4716 );
4717 assert!(
4718 is_noise(CLAUDE_CHECKLIST),
4719 "a progress checklist asks for nothing"
4720 );
4721 assert!(
4722 !is_noise(CLAUDE_FINDING),
4723 "a review that names a bug is input, not noise"
4724 );
4725
4726 let mut clean = pr(Checks::Green, &[], 0);
4727 clean.review_comments.push(ReviewComment {
4728 author: "coderabbitai".to_owned(),
4729 path: None,
4730 line: None,
4731 body: CODERABBIT_TRIGGER.to_owned(),
4732 });
4733 clean.review_comments.retain(|c| !is_noise(&c.body));
4734 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
4735
4736 let mut found = pr(Checks::Green, &[], 0);
4737 found.review_comments.push(ReviewComment {
4738 author: "claude".to_owned(),
4739 path: None,
4740 line: None,
4741 body: CLAUDE_FINDING.to_owned(),
4742 });
4743 found.review_comments.retain(|c| !is_noise(&c.body));
4744 assert!(matches!(
4745 decide(&found, 0, 4, Duration::ZERO),
4746 Step::Fix { .. }
4747 ));
4748 }
4749
4750 #[test]
4751 fn the_policy_table_holds_for_every_combination_that_matters() {
4752 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
4753 (
4754 "pending checks are waited for, even on the last round",
4755 pr(Checks::Pending, &[], 0),
4756 4,
4757 4,
4758 Duration::ZERO,
4759 Step::Wait,
4760 ),
4761 (
4762 "red checks are fixed",
4763 pr(Checks::Red, &["editorconfig"], 0),
4764 0,
4765 4,
4766 Duration::ZERO,
4767 Step::Fix {
4768 reason: "1 check(s) failing: editorconfig".to_owned(),
4769 },
4770 ),
4771 (
4772 "green with comments is fixed, not merged",
4773 pr(Checks::Green, &[], 2),
4774 1,
4775 4,
4776 Duration::ZERO,
4777 Step::Fix {
4778 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
4779 .to_owned(),
4780 },
4781 ),
4782 (
4783 "green and clean merges",
4784 pr(Checks::Green, &[], 0),
4785 3,
4786 4,
4787 Duration::ZERO,
4788 Step::Merge,
4789 ),
4790 (
4791 "an unreadable rollup is waited on while the grace lasts",
4792 pr(Checks::Unknown, &[], 0),
4793 0,
4794 4,
4795 Duration::ZERO,
4796 Step::Wait,
4797 ),
4798 (
4799 "an unreadable rollup is never merged once the grace is spent",
4800 pr(Checks::Unknown, &[], 0),
4801 0,
4802 4,
4803 CHECKS_GRACE,
4804 Step::GiveUp {
4805 reason: "no check status is readable on the pull request after 3 minute(s); \
4806 refusing to merge on a guess"
4807 .to_owned(),
4808 },
4809 ),
4810 ];
4811 for (what, state, round, budget, waited, want) in cases {
4812 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
4813 }
4814 }
4815
4816 #[test]
4817 fn the_forge_verdict_survives_the_round_trip_from_gh() {
4818 let green = parse_pr(GREEN_OPEN).expect("parse");
4822 assert_eq!(green.blocking, Blocking::No);
4823 let red = parse_pr(RED_OPEN).expect("parse");
4824 assert_eq!(
4825 red.blocking,
4826 Blocking::No,
4827 "`UNSTABLE` is mergeable: the red check is one nobody requires"
4828 );
4829 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
4830 let quiet =
4832 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
4833 assert_eq!(quiet.blocking, Blocking::Unsaid);
4834 }
4835
4836 #[test]
4837 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
4838 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
4844 nonblocking.blocking = Blocking::No;
4845 assert_eq!(
4846 decide(&nonblocking, 0, 4, Duration::ZERO),
4847 Step::Merge,
4848 "the forge says nothing is in the way, so nothing is"
4849 );
4850
4851 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
4853 blocking.blocking = Blocking::Yes;
4854 assert!(matches!(
4855 decide(&blocking, 0, 4, Duration::ZERO),
4856 Step::Fix { .. }
4857 ));
4858
4859 let mut commented = pr(Checks::Red, &["coverage"], 1);
4862 commented.blocking = Blocking::No;
4863 assert!(matches!(
4864 decide(&commented, 0, 4, Duration::ZERO),
4865 Step::Fix { .. }
4866 ));
4867
4868 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
4870 unsaid.blocking = Blocking::Unsaid;
4871 assert!(matches!(
4872 decide(&unsaid, 0, 4, Duration::ZERO),
4873 Step::Fix { .. }
4874 ));
4875 }
4876
4877 #[test]
4878 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
4879 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
4880 red.blocking = Blocking::No;
4881 assert_eq!(
4882 decide(&red, 0, 4, Duration::ZERO),
4883 Step::Merge,
4884 "announcing must not change the decision"
4885 );
4886 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
4887 assert!(said.contains("yukimemi/magi"), "{said}");
4888 assert!(said.contains("#16"), "{said}");
4889 assert!(
4890 said.contains("https://github.com/yukimemi/magi/pull/16"),
4891 "{said}"
4892 );
4893 assert!(
4894 said.contains("test (windows-latest)") && said.contains("coverage"),
4895 "{said}"
4896 );
4897
4898 let green = pr(Checks::Green, &["stale"], 0);
4900 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
4901 }
4902
4903 #[test]
4904 fn the_repo_label_comes_from_the_pull_request_url() {
4905 let p = Path::new("/tmp/checkout");
4906 assert_eq!(
4907 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
4908 "yukimemi/magi"
4909 );
4910 assert_eq!(repo_label(p, "not a url"), "checkout");
4911 }
4912
4913 #[test]
4914 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
4915 let mut conflicted = pr(Checks::Green, &[], 0);
4920 conflicted.blocking = Blocking::Conflict;
4921 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
4922
4923 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
4927 red.blocking = Blocking::Conflict;
4928 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
4929
4930 let mut merged = pr(Checks::Red, &[], 0);
4932 merged.blocking = Blocking::Conflict;
4933 merged.state = PrLifecycle::Merged;
4934 assert_eq!(
4935 decide(&merged, 0, 4, Duration::ZERO),
4936 Step::Done { merged: true }
4937 );
4938 }
4939
4940 #[test]
4941 fn the_forge_verdict_is_read_off_merge_state_status() {
4942 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
4945 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
4946 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
4947 }
4948 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
4949 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
4950 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
4951 for quiet in ["", "UNKNOWN"] {
4954 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
4955 assert!(Blocking::of(quiet).stops_a_merge());
4956 }
4957 }
4958
4959 #[test]
4960 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
4961 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
4962 let jj = "could not determine current branch: failed to run git: not on any branch";
4964
4965 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
4966 .expect("the forge says merged, so it merged");
4967 assert!(landed.ok);
4968 assert!(
4969 landed.detail.contains("but the pull request is merged"),
4970 "the record must not read as a clean success: {}",
4971 landed.detail
4972 );
4973 assert!(
4974 landed.detail.contains("not on any branch"),
4975 "and it must keep what the command actually said: {}",
4976 landed.detail
4977 );
4978
4979 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
4981 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
4982 assert!(merged_after_all(&argv, jj, None).is_none());
4984 }
4985
4986 #[test]
4987 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
4988 let mut state = pr(Checks::Red, &["editorconfig"], 3);
4989 state.state = PrLifecycle::Closed;
4990 assert_eq!(
4991 decide(&state, 0, 4, Duration::ZERO),
4992 Step::Done { merged: false },
4993 "a human closing the pull request ends the loop, whatever CI says"
4994 );
4995 }
4996
4997 #[test]
4998 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
4999 let red = decide(
5000 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5001 4,
5002 4,
5003 Duration::ZERO,
5004 );
5005 match red {
5006 Step::GiveUp { reason } => {
5007 assert!(reason.contains("editorconfig"), "reason: {reason}");
5008 assert!(reason.contains("test (macos)"), "reason: {reason}");
5009 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5010 }
5011 other => panic!("expected a give-up, got {other:?}"),
5012 }
5013
5014 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5015 match commented {
5016 Step::GiveUp { reason } => {
5017 assert!(reason.contains("unresolved"), "reason: {reason}");
5018 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5019 }
5020 other => panic!("expected a give-up, got {other:?}"),
5021 }
5022 }
5023
5024 #[test]
5025 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5026 let candidate_commit = "magi: candidate A (uncommitted work)";
5027 let subject = merge_subject(candidate_commit, "add retries to the uploader");
5028 let argv = merge_argv(16, &subject);
5029
5030 assert!(argv.contains(&"--squash".to_owned()));
5031 assert!(argv.contains(&"--delete-branch".to_owned()));
5032 assert!(argv.contains(&"--subject".to_owned()));
5033 assert_eq!(
5034 argv.last().map(String::as_str),
5035 Some("add retries to the uploader"),
5036 "the subject must not be the candidate commit message"
5037 );
5038 assert_ne!(subject, candidate_commit);
5039 }
5040
5041 #[test]
5042 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5043 assert_eq!(
5044 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5045 "feat: a queue, an unattended loop, and a phone UI"
5046 );
5047 assert_eq!(
5048 merge_subject("", "# port the retry logic\n\ndetails"),
5049 "port the retry logic",
5050 "an empty title falls back to the task's first line, heading marks stripped"
5051 );
5052 }
5053
5054 #[test]
5055 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5056 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5057 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5058 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5059 assert_eq!(job_of("https://coderabbit.ai/status"), None);
5060 assert_eq!(run_of(""), None);
5061 }
5062
5063 #[test]
5064 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5065 let mut out = Vec::new();
5066 push_if_outstanding(
5067 &mut out,
5068 ReviewComment {
5069 author: "yukimemi".to_owned(),
5070 path: None,
5071 line: None,
5072 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5073 },
5074 );
5075 assert!(out.is_empty());
5076 }
5077
5078 fn run_state() -> RunState {
5082 let mut state = RunState::new(
5083 std::path::PathBuf::from("/repo/magi"),
5084 "main".to_owned(),
5085 "abcdef1234".to_owned(),
5086 "add retries to the uploader".to_owned(),
5087 crate::config::Config::default(),
5088 );
5089 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5094 let nanos = std::time::SystemTime::now()
5095 .duration_since(std::time::UNIX_EPOCH)
5096 .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5097 state.id = format!(
5098 "20261004-{nanos:06}-{:04x}",
5099 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5100 );
5101 state
5102 }
5103
5104 fn green_pr() -> PrState {
5105 PrState {
5106 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5107 number: 42,
5108 state: PrLifecycle::Open,
5109 checks: Checks::Green,
5110 blocking: Blocking::No,
5112 failing: Vec::new(),
5113 review_comments: vec![ReviewComment {
5114 author: "coderabbitai".to_owned(),
5115 path: Some("src/land.rs".to_owned()),
5116 line: Some(212),
5117 body: "this branch never checks the exit code".to_owned(),
5118 }],
5119 }
5120 }
5121
5122 #[test]
5123 fn github_facing_land_text_is_english_whatever_the_language() {
5124 let mut state = run_state();
5125 state.config.graph.language = "ja".to_owned();
5126 let comment = stop_comment(&state.id, "checks are still red");
5127 assert!(comment.is_ascii(), "{comment}");
5128 assert!(comment.starts_with(MARKER));
5129
5130 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5131 let ja_at = p.find("Write all prose in ja").unwrap();
5132 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5133 assert!(ja_at < rule_at, "{p}");
5134 assert!(p.contains("stays in Japanese"), "{p}");
5135
5136 state.config.graph.language = "en".to_owned();
5137 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5138 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5139 assert!(!p.contains("does not apply"), "{p}");
5140 }
5141
5142 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5143
5144 fn panel() -> String {
5145 approval_panel(
5146 &run_state(),
5147 &green_pr(),
5148 NUMSTAT,
5149 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5150 &[
5151 "land: ask before merging".to_owned(),
5152 "land: colour the diff".to_owned(),
5153 ],
5154 "feat: merge approval from the phone",
5155 )
5156 }
5157
5158 #[test]
5159 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5160 let html = panel();
5161 for needle in [
5162 "42",
5163 "main",
5164 "src/land.rs",
5165 "src/web.rs",
5166 "assets/logo.png",
5167 "feat: merge approval from the phone",
5168 "land: ask before merging",
5169 "land: colour the diff",
5170 "coderabbitai",
5171 "this branch never checks the exit code",
5172 "green",
5173 ] {
5174 assert!(html.contains(needle), "the panel must state `{needle}`");
5175 }
5176 }
5177
5178 fn winning_candidate(summary: &str) -> Candidate {
5181 Candidate {
5182 index: 0,
5183 label: 'A',
5184 agent: "opus".to_owned(),
5185 branch: "magi/x/A".to_owned(),
5186 worktree: PathBuf::from("/wt/A"),
5187 summary: summary.to_owned(),
5188 stat: String::new(),
5189 files: 1,
5190 commits: 1,
5191 empty: false,
5192 failed: None,
5193 verified_noop: None,
5194 duration_ms: 0,
5195 folded: false,
5196 }
5197 }
5198
5199 fn uncontested_tally() -> Tally {
5200 Tally {
5201 first_choice: BTreeMap::from([('A', 1)]),
5202 borda: BTreeMap::new(),
5203 winner: 'A',
5204 rankings: 1,
5205 unanimous_initial: true,
5206 deliberated: false,
5207 changed_votes: 0,
5208 unanimous_final: true,
5209 tie_break: None,
5210 judges: 1,
5211 present: 1,
5212 quorum: 1,
5213 met_quorum: true,
5214 uncontested: None,
5215 }
5216 }
5217
5218 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5219 ReviewRecord {
5220 attempts: 0,
5221 reviewer,
5222 agent: agent.to_owned(),
5223 summary: summary.to_owned(),
5224 findings: Vec::new(),
5225 vote: None,
5226 failed: None,
5227 duration_ms: 0,
5228 }
5229 }
5230
5231 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5232 let answered = reviews.len();
5233 ReviewRound {
5234 round,
5235 head: "abc1234".to_owned(),
5236 verified_head: None,
5237 verified_at: None,
5238 reviews,
5239 e2e: Vec::new(),
5240 verify_retried: false,
5241 e2e_deferred: false,
5242 e2e_defer_reason: None,
5243 fix: None,
5244 blocking: 0,
5245 answered,
5246 expected: answered,
5247 clean: true,
5248 progressed: false,
5249 vote_split: false,
5250 reconsideration: Vec::new(),
5251 verdict: None,
5252 }
5253 }
5254
5255 #[test]
5256 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5257 let en = panel();
5258 assert!(en.contains("Task"), "{en}");
5259 assert!(en.contains("add retries to the uploader"), "{en}");
5260
5261 let mut state = run_state();
5262 state.config.graph.language = "ja".to_owned();
5263 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5264 assert!(ja.contains("タスク"), "{ja}");
5265 assert!(
5266 ja.contains("add retries to the uploader"),
5267 "the task itself is not translated: {ja}"
5268 );
5269 }
5270
5271 #[test]
5272 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5273 let html = panel();
5277 assert!(!html.contains("What changed"), "{html}");
5278 assert!(!html.contains("Review verdict"), "{html}");
5279 }
5280
5281 #[test]
5282 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5283 let mut state = run_state();
5284 state.candidates = vec![winning_candidate("")];
5285 state.tally = Some(uncontested_tally());
5286 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5287 assert!(
5288 !html.contains("What changed"),
5289 "an empty summary must not render an empty box: {html}"
5290 );
5291 }
5292
5293 #[test]
5294 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5295 let mut state = run_state();
5296 state.candidates = vec![winning_candidate(
5297 "Added a retry loop around the uploader PUT call.",
5298 )];
5299 state.tally = Some(uncontested_tally());
5300 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5301 assert!(en.contains("What changed"), "{en}");
5302 assert!(
5303 en.contains("Added a retry loop around the uploader PUT call."),
5304 "{en}"
5305 );
5306
5307 state.config.graph.language = "ja".to_owned();
5308 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5309 assert!(ja.contains("変更内容"), "{ja}");
5310 assert!(
5311 ja.contains("Added a retry loop around the uploader PUT call."),
5312 "{ja}"
5313 );
5314 }
5315
5316 #[test]
5317 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5318 let mut state = run_state();
5319 state.reviews = vec![
5320 review_round(
5321 1,
5322 vec![review_record(1, "alpha", "found a race, sent back")],
5323 ),
5324 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5325 ];
5326 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5327 assert!(en.contains("Review verdict"), "{en}");
5328 assert!(en.contains("race is fixed, clean"), "{en}");
5329 assert!(
5330 !en.contains("found a race, sent back"),
5331 "only the round that actually cleared the merge should show: {en}"
5332 );
5333
5334 state.config.graph.language = "ja".to_owned();
5335 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5336 assert!(ja.contains("レビューの結論"), "{ja}");
5337 assert!(ja.contains("レビュアー"), "{ja}");
5338 assert!(ja.contains("race is fixed, clean"), "{ja}");
5339 }
5340
5341 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5347 ReviewRecord {
5348 attempts: 0,
5349 reviewer,
5350 agent: agent.to_owned(),
5351 summary: String::new(),
5352 findings: Vec::new(),
5353 vote: None,
5354 failed: Some(reason.to_owned()),
5355 duration_ms: 0,
5356 }
5357 }
5358
5359 #[test]
5360 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5361 let mut state = run_state();
5362 state.reviews = vec![review_round(
5363 1,
5364 vec![
5365 review_record(1, "alpha", "clean, nothing to add"),
5366 unanswered_review_record(2, "beta", "timed out"),
5367 ],
5368 )];
5369 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5370 assert!(en.contains("clean, nothing to add"), "{en}");
5371 assert!(
5372 en.contains("produced no answer: timed out"),
5373 "a seat that never answered must say so, not render a blank box: {en}"
5374 );
5375 assert!(
5376 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
5377 "no reviewer box may be left empty: {en}"
5378 );
5379
5380 state.config.graph.language = "ja".to_owned();
5381 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5382 assert!(ja.contains("回答なし: timed out"), "{ja}");
5383 }
5384
5385 #[test]
5386 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
5387 let html = panel();
5388 assert!(!html.contains("<script"), "no script survives the csp");
5389 assert!(!html.contains("<form"), "form-action is 'none'");
5390 let pr = green_pr();
5391 assert_eq!(
5392 html.matches("http").count(),
5393 html.matches(pr.url.as_str()).count(),
5394 "the only http url in the panel is the pull request's own link"
5395 );
5396 }
5397
5398 #[test]
5399 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
5400 let html = panel();
5401 assert!(
5402 html.contains(">+</span>"),
5403 "an added line carries a `+` in the gutter, not only a background"
5404 );
5405 assert!(
5406 html.contains(">-</span>"),
5407 "a removed line carries a `-` in the gutter, not only a background"
5408 );
5409 assert!(
5410 html.contains(">new line</span>"),
5411 "the marker is moved to the gutter, so the body is printed once without it"
5412 );
5413 }
5414
5415 #[test]
5416 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
5417 let total = DIFF_MAX_LINES + 100;
5418 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
5419 let html = approval_panel(
5420 &run_state(),
5421 &green_pr(),
5422 NUMSTAT,
5423 &diff,
5424 &[],
5425 "feat: something long",
5426 );
5427 assert!(
5428 html.contains(&format!("100 of {total} diff lines omitted")),
5429 "the note must say exactly how much was cut"
5430 );
5431 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
5432 assert!(
5433 !html.contains(&format!("line {DIFF_MAX_LINES}")),
5434 "nothing past the threshold is rendered"
5435 );
5436 assert!(
5437 html.contains("/repo/magi"),
5438 "the note says where the rest is"
5439 );
5440 }
5441
5442 #[test]
5443 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
5444 let html = approval_panel(
5445 &run_state(),
5446 &green_pr(),
5447 "1\t2\tsrc/<b>&\"x\"'.rs",
5448 "",
5449 &[],
5450 "subject",
5451 );
5452 assert!(html.contains("src/<b>&"x"'.rs"));
5453 assert!(
5454 !html.contains("<b>"),
5455 "an agent-influenced path must never become markup"
5456 );
5457 }
5458
5459 #[tokio::test]
5460 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
5461 let a = std::path::PathBuf::from("/repo/a");
5462 let b = std::path::PathBuf::from("/repo/b");
5463
5464 let held = repo_merge_lock(&a).lock_owned().await;
5465
5466 assert!(
5469 repo_merge_lock(&a).try_lock().is_err(),
5470 "a second merge into the same repository must not proceed concurrently"
5471 );
5472
5473 assert!(
5477 repo_merge_lock(&b).try_lock().is_ok(),
5478 "a different repository's merge lock must be independent"
5479 );
5480
5481 drop(held);
5482 assert!(
5483 repo_merge_lock(&a).try_lock().is_ok(),
5484 "the lock is released once the holder is done"
5485 );
5486 }
5487
5488 #[test]
5489 fn only_the_merge_choice_merges_and_silence_holds() {
5490 let table = [
5491 (None, Approval::Hold),
5492 (Some("merge"), Approval::Merge),
5493 (Some(" merge\n"), Approval::Merge),
5494 (Some("hold"), Approval::Hold),
5495 (Some(""), Approval::Hold),
5496 (Some("yes"), Approval::Hold),
5497 ];
5498 for (answer, want) in table {
5499 assert_eq!(
5500 approval(answer),
5501 want,
5502 "answer {answer:?} must resolve to {want:?}"
5503 );
5504 }
5505 }
5506
5507 #[tokio::test]
5508 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
5509 let mut state = landing_state();
5510 state.config.graph.land_approval = true;
5511 let pr = green_pr();
5512
5513 let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5514 .await
5515 .unwrap();
5516 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
5517 assert!(
5518 !state.parked,
5519 "approval_gate itself never sets `parked`; only its caller does"
5520 );
5521
5522 let store = ask::Questions::open();
5523 let filed: Vec<_> = store
5524 .list()
5525 .into_iter()
5526 .filter(|q| q.run == state.id)
5527 .collect();
5528 assert_eq!(filed.len(), 1, "exactly one question is filed");
5529 assert_eq!(filed[0].node, APPROVAL_NODE);
5530 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
5531 assert!(filed[0].status.open());
5532
5533 let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
5537 .await
5538 .unwrap();
5539 assert_eq!(again, ApprovalGate::Pending);
5540 let still_one = store
5541 .list()
5542 .into_iter()
5543 .filter(|q| q.run == state.id)
5544 .count();
5545 assert_eq!(
5546 still_one, 1,
5547 "asking twice must not double-file the question"
5548 );
5549 }
5550
5551 #[tokio::test]
5552 async fn approving_the_existing_question_is_read_back_as_approved() {
5553 crate::run::pin_test_home();
5554 let mut state = run_state();
5555 state.config.graph.land_approval = true;
5556 let pr = green_pr();
5557 assert_eq!(
5558 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5559 .await
5560 .unwrap(),
5561 ApprovalGate::Pending
5562 );
5563
5564 let store = ask::Questions::open();
5565 let mut q = store
5566 .list()
5567 .into_iter()
5568 .find(|q| q.run == state.id)
5569 .expect("filed above");
5570 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5571 store.put(&mut q).unwrap();
5572
5573 assert_eq!(
5574 approval_gate(&mut state, &pr, "feat: x", None, "abc")
5575 .await
5576 .unwrap(),
5577 ApprovalGate::Approved
5578 );
5579 }
5580
5581 #[tokio::test]
5582 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
5583 crate::run::pin_test_home();
5584 let store = ask::Questions::open();
5585
5586 let mut held_state = run_state();
5587 held_state.config.graph.land_approval = true;
5588 let pr = green_pr();
5589 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5590 .await
5591 .unwrap();
5592 let mut q = store
5593 .list()
5594 .into_iter()
5595 .find(|q| q.run == held_state.id)
5596 .expect("filed above");
5597 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
5598 store.put(&mut q).unwrap();
5599 assert_eq!(
5600 approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
5601 .await
5602 .unwrap(),
5603 ApprovalGate::Held
5604 );
5605
5606 let mut abandoned_state = run_state();
5607 abandoned_state.config.graph.land_approval = true;
5608 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5609 .await
5610 .unwrap();
5611 let mut q = store
5612 .list()
5613 .into_iter()
5614 .find(|q| q.run == abandoned_state.id)
5615 .expect("filed above");
5616 q.abandon("no answer within the timeout");
5617 store.put(&mut q).unwrap();
5618 assert_eq!(
5619 approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
5620 .await
5621 .unwrap(),
5622 ApprovalGate::Held,
5623 "silence must never merge"
5624 );
5625 }
5626
5627 fn contested() -> ContestedHandoff {
5628 let finding = |id: &str, n: u32| crate::verdict::Finding {
5629 id: id.to_owned(),
5630 severity: crate::verdict::Severity::Major,
5631 file: Some("src/a.rs".to_owned()),
5632 line: Some(n),
5633 title: format!("problem {id}"),
5634 detail: String::new(),
5635 };
5636 ContestedHandoff {
5637 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
5638 rejecters: vec![(1, "alpha".to_owned())],
5639 }
5640 }
5641
5642 #[test]
5643 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
5644 let mut state = run_state();
5645 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
5646 state.contested_handoff = Some(contested());
5647 assert!(contested_to_ask(&state).is_some());
5648 state.config.graph.hold_contested_merge = false;
5649 assert!(
5650 contested_to_ask(&state).is_none(),
5651 "the switch restores today"
5652 );
5653 }
5654
5655 #[test]
5656 fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
5657 let q = ask::Question::new(
5658 "run-1".to_owned(),
5659 APPROVAL_NODE.to_owned(),
5660 "land".to_owned(),
5661 "Merge?".to_owned(),
5662 String::new(),
5663 vec![APPROVE.to_owned(), HOLD.to_owned()],
5664 );
5665 let none = deputy_brief(&q, None);
5666 assert!(none.contains("could not be read"), "{none}");
5667 assert!(none.contains("Silence is a hold"), "{none}");
5668
5669 let mut state = run_state();
5670 state.pr = Some(crate::run::PrRecord {
5671 url: "https://example.test/pull/7".to_owned(),
5672 number: 7,
5673 state: "open".to_owned(),
5674 checks: "green".to_owned(),
5675 round: 0,
5676 rounds: 3,
5677 red_at_merge: Vec::new(),
5678 });
5679 state.contested_handoff = Some(contested());
5680 let b = deputy_brief(&q, Some(&state));
5681 assert!(b.contains("https://example.test/pull/7"), "{b}");
5682 assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
5683 assert!(b.contains("#1"), "the rejecting seat: {b}");
5684 state.contested_handoff = None;
5685 assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
5686 }
5687
5688 #[test]
5689 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
5690 for lang in ["en", "ja"] {
5691 let mut cfg = crate::config::Config::default();
5692 cfg.graph.language = lang.to_owned();
5693 let w = words(&cfg.graph.language);
5694 let text = w.approval_detail(
5695 "https://github.com/yukimemi/magi/pull/42",
5696 "main",
5697 "feat: x",
5698 Some(&contested()),
5699 );
5700 assert!(text.contains("pull/42"), "{text}");
5701 assert!(
5702 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
5703 "{text}"
5704 );
5705 assert!(text.contains("R3-1-5"), "{text}");
5706 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
5707 assert!(text.contains("2"), "the rest are counted: {text}");
5708 assert!(text.contains("#1 (alpha)"), "{text}");
5709 }
5710 let plain = words("en").approval_detail("u", "main", "s", None);
5711 assert!(!plain.contains("reject"), "{plain}");
5712 }
5713
5714 #[tokio::test]
5715 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
5716 crate::run::pin_test_home();
5717 let mut state = run_state();
5718 state.config.graph.land_approval = false;
5719 state.contested_handoff = Some(contested());
5720 let pr = green_pr();
5721 let c = contested_to_ask(&state);
5722 assert_eq!(
5723 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5724 .await
5725 .unwrap(),
5726 ApprovalGate::Pending,
5727 "silence is a hold"
5728 );
5729 let store = ask::Questions::open();
5730 let filed: Vec<_> = store
5731 .list()
5732 .into_iter()
5733 .filter(|q| q.run == state.id)
5734 .collect();
5735 assert_eq!(filed.len(), 1);
5736 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
5737
5738 assert_eq!(
5739 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5740 .await
5741 .unwrap(),
5742 ApprovalGate::Pending
5743 );
5744 let mut q = store
5745 .list()
5746 .into_iter()
5747 .find(|q| q.run == state.id)
5748 .unwrap();
5749 assert_eq!(q.id, filed[0].id, "the same question after a resume");
5750 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
5751 store.put(&mut q).unwrap();
5752 assert_eq!(
5753 approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
5754 .await
5755 .unwrap(),
5756 ApprovalGate::Approved
5757 );
5758 }
5759
5760 #[test]
5761 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
5762 let rows = parse_numstat(NUMSTAT);
5763 assert_eq!(
5764 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
5765 ["src/web.rs", "src/land.rs", "assets/logo.png"]
5766 );
5767 assert_eq!(rows[2].added, None, "a binary file has no line counts");
5768 }
5769 #[test]
5770 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
5771 let mut state = run_state();
5775 state.config.graph.language = "ja".to_owned();
5776 let pr = green_pr();
5777 let commits = ["c1".to_owned()];
5778
5779 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5780 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
5781 assert!(ja.contains("squash されるコミット"), "{ja}");
5782 assert!(ja.contains("レビューコメント"), "{ja}");
5783 assert!(ja.contains("差分"), "{ja}");
5784 assert!(
5785 !ja.contains("Commits being squashed"),
5786 "no English left over"
5787 );
5788
5789 let w = words("ja");
5790 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
5791 assert!(
5792 w.approval_detail("http://x/1", "main", "feat: x", None)
5793 .contains("パネル")
5794 );
5795
5796 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
5798 assert!(ja.contains("feat: x"), "nor is the merge subject");
5799
5800 state.config.graph.language = "en".to_owned();
5803 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
5804 assert!(en.contains("Commits being squashed"), "{en}");
5805 assert_eq!(words("Klingon").html_lang, "en");
5806 }
5807
5808 #[test]
5812 fn pick_open_pr_classifies_by_count_and_base() {
5813 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
5814 assert_eq!(
5815 pick_open_pr(one, "main").unwrap(),
5816 OpenPr::One {
5817 url: "https://x/pull/58".into(),
5818 title: "t".into()
5819 }
5820 );
5821 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
5822 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
5823 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
5824 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
5825 assert_eq!(
5826 pick_open_pr(two, "main").unwrap(),
5827 OpenPr::Many(vec!["u1".into(), "u2".into()])
5828 );
5829 assert!(pick_open_pr("not json", "main").is_err());
5830 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
5832 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
5833 }
5834
5835 #[test]
5836 fn pick_merged_pr_picks_the_unique_match() {
5837 let json = r#"[
5838 {"url": "https://github.com/o/r/pull/42", "number": 42,
5839 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
5840 ]"#;
5841 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5842 let found = pick_merged_pr(json, "main", created_at)
5843 .expect("valid json")
5844 .expect("one unambiguous match");
5845 assert_eq!(found.url, "https://github.com/o/r/pull/42");
5846 assert_eq!(found.number, 42);
5847 }
5848
5849 #[test]
5853 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
5854 let json = r#"[
5855 {"url": "https://github.com/o/r/pull/42", "number": 42,
5856 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
5857 {"url": "https://github.com/o/r/pull/43", "number": 43,
5858 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
5859 ]"#;
5860 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5861 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5862 }
5863
5864 #[test]
5868 fn pick_merged_pr_ignores_a_different_base_branch() {
5869 let json = r#"[
5870 {"url": "https://github.com/o/r/pull/42", "number": 42,
5871 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
5872 ]"#;
5873 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5874 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5875 }
5876
5877 #[test]
5880 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
5881 let json = r#"[
5882 {"url": "https://github.com/o/r/pull/42", "number": 42,
5883 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
5884 ]"#;
5885 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
5886 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
5887 }
5888
5889 #[test]
5890 fn slug_of_pr_url_reads_host_owner_and_repo() {
5891 assert_eq!(
5892 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
5893 Some("github.com/yukimemi/shun")
5894 );
5895 }
5896
5897 #[test]
5898 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
5899 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
5900 assert_eq!(slug_of_pr_url("not a url at all"), None);
5901 assert_eq!(slug_of_pr_url("https://github.com"), None);
5902 }
5903
5904 #[test]
5905 fn slug_of_repo_url_reads_host_owner_and_repo() {
5906 assert_eq!(
5907 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
5908 Some("github.com/yukimemi/magi")
5909 );
5910 assert_eq!(slug_of_repo_url("https://github.com"), None);
5911 }
5912
5913 #[test]
5914 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
5915 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
5916 .expect("same repo, different case");
5917 }
5918
5919 #[test]
5924 fn ensure_same_repo_refuses_a_different_repo() {
5925 let err =
5926 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
5927 let msg = format!("{err:#}");
5928 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
5929 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
5930 }
5931
5932 #[test]
5937 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
5938 let err = ensure_same_repo(
5939 "github.com/yukimemi/magi",
5940 "github.example.com/yukimemi/magi",
5941 )
5942 .unwrap_err();
5943 let msg = format!("{err:#}");
5944 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
5945 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
5946 }
5947
5948 #[tokio::test]
5953 async fn find_external_merge_returns_none_without_a_winner() {
5954 let state = RunState::new(
5955 PathBuf::from("/no/such/repo"),
5956 "main".to_owned(),
5957 "0000000000000000000000000000000000000000".to_owned(),
5958 "irrelevant".to_owned(),
5959 crate::config::Config::default(),
5960 );
5961 assert_eq!(find_external_merge(&state).await.unwrap(), None);
5962 }
5963
5964 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
5965 let mut run = RunState::new(
5966 PathBuf::from(repo),
5967 "main".to_owned(),
5968 "abcdef1234".to_owned(),
5969 "x".to_owned(),
5970 crate::config::Config::default(),
5971 );
5972 run.id = id.to_owned();
5973 run.status = status;
5974 run.pr = Some(crate::run::PrRecord {
5975 number: url.rsplit('/').next().unwrap().parse().unwrap(),
5976 url: url.to_owned(),
5977 state: state.to_owned(),
5978 checks: "red".to_owned(),
5979 round: 0,
5980 rounds: 2,
5981 red_at_merge: Vec::new(),
5982 });
5983 run.save_under(home).unwrap();
5984 }
5985
5986 fn recorded(home: &Path, id: &str) -> String {
5987 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
5988 serde_json::from_str::<RunState>(&body)
5989 .unwrap()
5990 .pr
5991 .unwrap()
5992 .state
5993 }
5994
5995 const PR: &str = "https://github.com/o/r/pull/7";
5996
5997 #[test]
5998 fn write_through_updates_predecessors_and_siblings_only() {
5999 let tmp = tempfile::tempdir().unwrap();
6000 let h = tmp.path();
6001 pr_run(
6002 h,
6003 "20261004-100000-aaaa",
6004 "/repo/r",
6005 RunStatus::Superseded,
6006 PR,
6007 "open",
6008 );
6009 pr_run(
6010 h,
6011 "20261004-100100-bbbb",
6012 "/repo/r",
6013 RunStatus::Blocked,
6014 PR,
6015 "open",
6016 );
6017 pr_run(
6019 h,
6020 "20261004-100200-cccc",
6021 "/repo/r",
6022 RunStatus::Landing,
6023 PR,
6024 "open",
6025 );
6026 pr_run(
6028 h,
6029 "20261004-100300-dddd",
6030 "/repo/other",
6031 RunStatus::Blocked,
6032 "https://github.com/o/other/pull/7",
6033 "open",
6034 );
6035 pr_run(
6037 h,
6038 "20261004-100400-eeee",
6039 "/repo/r",
6040 RunStatus::Blocked,
6041 "https://github.com/o/r/pull/8",
6042 "open",
6043 );
6044 pr_run(
6045 h,
6046 "20261004-100500-ffff",
6047 "/repo/r",
6048 RunStatus::Merged,
6049 PR,
6050 "open",
6051 );
6052 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6053
6054 assert_eq!(
6055 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6056 2
6057 );
6058 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6059 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6060 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6061 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6062 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6063 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6065 assert_eq!(
6067 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6068 0
6069 );
6070 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6071 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6072 }
6073
6074 #[test]
6075 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6076 let tmp = tempfile::tempdir().unwrap();
6077 let h = tmp.path();
6078 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6079 pr_run(
6080 h,
6081 "20261004-100000-aaaa",
6082 "/repo/r",
6083 RunStatus::Superseded,
6084 &url(1),
6085 "open",
6086 );
6087 pr_run(
6088 h,
6089 "20261004-100100-bbbb",
6090 "/repo/r",
6091 RunStatus::Blocked,
6092 &url(2),
6093 "open",
6094 );
6095 pr_run(
6096 h,
6097 "20261004-100200-cccc",
6098 "/repo/r",
6099 RunStatus::Ready,
6100 &url(3),
6101 "open",
6102 );
6103 pr_run(
6104 h,
6105 "20261004-100300-dddd",
6106 "/repo/r",
6107 RunStatus::Ready,
6108 &url(4),
6109 "open",
6110 );
6111 pr_run(
6112 h,
6113 "20261004-100400-eeee",
6114 "/repo/r",
6115 RunStatus::Implementing,
6116 &url(1),
6117 "open",
6118 );
6119 assert_eq!(stale_open_prs(h).len(), 4);
6120
6121 let mut known = BTreeMap::new();
6122 known.insert(url(1), PrLifecycle::Merged);
6123 known.insert(url(2), PrLifecycle::Closed);
6124 known.insert(url(3), PrLifecycle::Open);
6125 assert_eq!(apply_pr_states(h, &known), 2);
6127 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6128 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6129 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6130 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6131 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6132 assert_eq!(apply_pr_states(h, &known), 0);
6133 }
6134
6135 use std::collections::VecDeque;
6138 use std::sync::Mutex;
6139
6140 struct Scripted {
6143 views: Mutex<VecDeque<Seen>>,
6144 merges: Mutex<VecDeque<(bool, String)>>,
6145 fix: Mutex<Option<Fixed>>,
6146 log: Mutex<Vec<&'static str>>,
6147 argvs: Mutex<Vec<Vec<String>>>,
6148 required: Mutex<Option<BTreeSet<String>>>,
6149 merged: Mutex<bool>,
6152 queued: Mutex<bool>,
6153 unreadable_after_merge: Mutex<bool>,
6155 }
6156
6157 impl Scripted {
6158 fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6159 Self {
6160 views: Mutex::new(views.into()),
6161 merges: Mutex::new(
6162 merges
6163 .into_iter()
6164 .map(|(ok, m)| (ok, m.to_owned()))
6165 .collect(),
6166 ),
6167 fix: Mutex::new(None),
6168 log: Mutex::new(Vec::new()),
6169 argvs: Mutex::new(Vec::new()),
6170 required: Mutex::new(None),
6171 merged: Mutex::new(false),
6172 queued: Mutex::new(false),
6173 unreadable_after_merge: Mutex::new(false),
6174 }
6175 }
6176 fn argvs(&self) -> Vec<Vec<String>> {
6177 self.argvs.lock().unwrap().clone()
6178 }
6179 fn calls(&self) -> Vec<&'static str> {
6180 self.log.lock().unwrap().clone()
6181 }
6182 }
6183
6184 impl Forge for Scripted {
6185 async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
6186 self.log.lock().unwrap().push("view");
6187 if *self.unreadable_after_merge.lock().unwrap()
6188 && !self.argvs.lock().unwrap().is_empty()
6189 {
6190 anyhow::bail!("forge unreachable");
6191 }
6192 let mut v = self.views.lock().unwrap();
6193 let mut seen = if v.len() > 1 {
6194 v.pop_front().unwrap()
6195 } else {
6196 v[0].clone()
6197 };
6198 if *self.merged.lock().unwrap() {
6199 seen.pr.state = PrLifecycle::Merged;
6200 }
6201 Ok(seen)
6202 }
6203 async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
6204 self.log.lock().unwrap().push("merge");
6205 self.argvs.lock().unwrap().push(argv.to_vec());
6206 let out = self
6207 .merges
6208 .lock()
6209 .unwrap()
6210 .pop_front()
6211 .expect("unscripted merge");
6212 if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
6213 {
6214 *self.merged.lock().unwrap() = true;
6215 }
6216 Ok(out)
6217 }
6218 async fn poll(&self) {
6219 self.log.lock().unwrap().push("poll");
6220 }
6221 async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
6222 self.required.lock().unwrap().clone()
6223 }
6224 async fn fix(
6225 &self,
6226 _state: &mut RunState,
6227 _pr: &PrState,
6228 _round: usize,
6229 _budget: usize,
6230 _reason: &str,
6231 _logs: &str,
6232 ) -> Result<Fixed> {
6233 self.log.lock().unwrap().push("fix");
6234 Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
6235 }
6236 }
6237
6238 const REFUSED: &str =
6239 "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
6240
6241 fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
6242 let mut pr = green_pr();
6243 pr.checks = checks;
6244 pr.blocking = Blocking::of(merge_state);
6245 if !comments {
6246 pr.review_comments.clear();
6247 }
6248 Seen {
6249 pr,
6250 title: "feat: x".to_owned(),
6251 failing_urls: Vec::new(),
6252 head: head.to_owned(),
6253 rollup_head: head.to_owned(),
6254 merge_state: merge_state.to_owned(),
6255 contexts: Vec::new(),
6256 base: "main".to_owned(),
6257 }
6258 }
6259
6260 fn landing_state() -> RunState {
6261 crate::run::pin_test_home();
6262 let mut state = run_state();
6263 state.config.graph.land_approval = false;
6264 state
6265 }
6266
6267 #[test]
6268 fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
6269 assert!(!awaiting_new_head(None, "aaa"));
6270 assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
6271 assert!(awaiting_new_head(Some("abc123"), "def456"));
6272 assert!(awaiting_new_head(Some("abc123"), ""));
6273 }
6274
6275 #[test]
6276 fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
6277 let open = |c, m: &str| seen("a", c, m, false);
6278 let table = [
6279 (None, false, Refused::Pending),
6280 (
6281 Some(open(Checks::Pending, "BLOCKED")),
6282 false,
6283 Refused::Pending,
6284 ),
6285 (
6286 Some(open(Checks::Unknown, "BLOCKED")),
6287 false,
6288 Refused::Pending,
6289 ),
6290 (
6291 Some(open(Checks::Green, "UNKNOWN")),
6292 false,
6293 Refused::Pending,
6294 ),
6295 (Some(open(Checks::Green, "")), false, Refused::Pending),
6296 (
6297 Some(open(Checks::Green, "BLOCKED")),
6298 false,
6299 Refused::Recheck,
6300 ),
6301 (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
6302 ];
6303 for (after, rechecked, want) in table {
6304 assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
6305 }
6306 let mut closed = open(Checks::Green, "CLEAN");
6307 closed.pr.state = PrLifecycle::Closed;
6308 assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
6309 }
6310
6311 #[tokio::test]
6312 async fn a_normal_landing_merges_on_the_first_look() {
6313 let mut state = landing_state();
6314 let forge = Scripted::new(
6315 vec![seen("a", Checks::Green, "CLEAN", false)],
6316 vec![(true, "")],
6317 );
6318 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6319 .await
6320 .unwrap();
6321 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6323 assert_eq!(state.status, RunStatus::Merged);
6324 }
6325
6326 #[tokio::test]
6327 async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
6328 let mut state = landing_state();
6329 let forge = Scripted::new(
6330 vec![seen("a", Checks::Green, "CLEAN", false)],
6331 std::iter::repeat_n((true, ""), 100).collect(),
6332 );
6333 *forge.queued.lock().unwrap() = true;
6334 let task = async {
6335 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6336 .await
6337 .unwrap();
6338 };
6339 task.await;
6342 assert_ne!(state.status, RunStatus::Merged);
6343 }
6344
6345 #[tokio::test]
6346 async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
6347 let mut state = landing_state();
6348 let forge = Scripted::new(
6349 vec![seen("a", Checks::Green, "CLEAN", false)],
6350 std::iter::repeat_n((true, ""), 100).collect(),
6351 );
6352 *forge.unreadable_after_merge.lock().unwrap() = true;
6353 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6354 .await
6355 .ok();
6356 assert_ne!(state.status, RunStatus::Merged);
6357 }
6358
6359 #[tokio::test]
6360 async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
6361 let mut state = landing_state();
6362 let forge = Scripted::new(
6363 vec![
6364 seen("old", Checks::Green, "CLEAN", true),
6365 seen("old", Checks::Green, "CLEAN", true),
6367 seen("new", Checks::Pending, "BLOCKED", true),
6368 seen("new", Checks::Green, "CLEAN", true),
6369 ],
6370 vec![(true, "")],
6371 );
6372 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6373 head: "NEW".to_owned(),
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", "fix", "poll", "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_head_that_never_arrives_stops_naming_both_commits() {
6390 let mut state = landing_state();
6391 let forge = Scripted::new(
6392 vec![
6393 seen("old", Checks::Green, "CLEAN", true),
6394 seen("someone-elses", Checks::Green, "CLEAN", true),
6395 ],
6396 vec![],
6397 );
6398 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6399 head: "mine".to_owned(),
6400 });
6401 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6402 .await
6403 .unwrap();
6404 assert!(!forge.calls().contains(&"merge"));
6405 let why = state.merge.as_ref().unwrap().detail.clone();
6406 assert!(
6407 why.contains("mine") && why.contains("someone-elses"),
6408 "{why}"
6409 );
6410 assert_eq!(state.status, RunStatus::Blocked);
6411 }
6412
6413 #[tokio::test]
6414 async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
6415 let mut state = landing_state();
6416 let forge = Scripted::new(
6417 vec![
6418 seen("a", Checks::Green, "CLEAN", false),
6419 seen("a", Checks::Green, "CLEAN", false),
6420 seen("a", Checks::Pending, "BLOCKED", false),
6421 seen("a", Checks::Pending, "BLOCKED", false),
6422 seen("a", Checks::Green, "CLEAN", false),
6423 ],
6424 vec![(false, REFUSED), (true, "")],
6425 );
6426 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6427 .await
6428 .unwrap();
6429 assert_eq!(
6430 forge.calls(),
6431 [
6432 "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
6433 "view"
6434 ]
6435 );
6436 assert_eq!(state.status, RunStatus::Merged);
6437 }
6438
6439 #[tokio::test]
6440 async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
6441 let mut state = landing_state();
6442 let forge = Scripted::new(
6443 vec![
6444 seen("a", Checks::Green, "CLEAN", false),
6445 seen("a", Checks::Green, "BLOCKED", false),
6446 ],
6447 vec![(false, REFUSED), (false, REFUSED)],
6448 );
6449 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6450 .await
6451 .unwrap();
6452 assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
6454 let why = state.merge.as_ref().unwrap().detail.clone();
6455 assert!(
6456 why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
6457 "{why}"
6458 );
6459 assert_eq!(state.status, RunStatus::Blocked);
6460 }
6461
6462 #[test]
6463 fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
6464 assert_eq!(bound_head("abc", "abc", None), Some("abc"));
6465 assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
6466 assert_eq!(bound_head("old", "old", Some("new")), None);
6468 assert_eq!(bound_head("new", "old", Some("new")), None);
6470 assert_eq!(bound_head("new", "old", None), None);
6471 assert_eq!(bound_head("", "", None), None);
6473 assert_eq!(bound_head("", "", Some("new")), None);
6474 assert_eq!(bound_head("abc", "", None), None);
6475 }
6476
6477 fn view_json(head: &str) -> String {
6478 format!(
6479 r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
6480 "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
6481 "reviews":[],"comments":[]}}"#
6482 )
6483 }
6484
6485 fn node_json(oid: &str, check: &str, has_next: bool) -> String {
6486 format!(
6487 r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
6488 {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
6489 "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
6490 "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
6491 )
6492 }
6493
6494 #[test]
6495 fn rollup_is_bound_to_the_commit_in_the_same_node() {
6496 let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
6499 assert_eq!(s.rollup_head, "old");
6500 assert_eq!(s.pr.checks, Checks::Red);
6501 assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
6502 assert_eq!(s.failing_urls.len(), 1);
6503 }
6504
6505 #[test]
6506 fn checks_come_from_the_node_not_the_view() {
6507 let view = view_json("new").replace(
6508 r#""reviews""#,
6509 r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
6510 );
6511 let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
6512 assert_eq!(s.pr.checks, Checks::Green);
6513 assert!(s.pr.failing.is_empty());
6514 assert_eq!(
6515 bound_head(&s.head, &s.rollup_head, Some("new")),
6516 Some("new")
6517 );
6518 }
6519
6520 #[test]
6521 fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
6522 for node in [
6523 None,
6524 Some("not json".to_owned()),
6525 Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
6526 Some(node_json("new", "SUCCESS", true)),
6527 ] {
6528 let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
6529 assert!(s.rollup_head.is_empty());
6530 assert_eq!(s.pr.checks, Checks::Unknown);
6531 assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
6532 }
6533 }
6534
6535 #[test]
6536 fn the_merge_command_is_pinned_to_the_observed_head() {
6537 let argv = merge_argv_at(7, "feat: x", "deadbeef");
6538 let at = argv
6539 .iter()
6540 .position(|a| a == "--match-head-commit")
6541 .unwrap();
6542 assert_eq!(argv[at + 1], "deadbeef");
6543 }
6544
6545 #[tokio::test]
6546 async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
6547 let mut state = landing_state();
6548 let mut stale = seen("new", Checks::Red, "CLEAN", false);
6551 stale.rollup_head = "old".to_owned();
6552 let forge = Scripted::new(
6553 vec![seen("old", Checks::Green, "CLEAN", true), stale],
6554 vec![],
6555 );
6556 *forge.fix.lock().unwrap() = Some(Fixed::Committed {
6557 head: "new".to_owned(),
6558 });
6559 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6560 .await
6561 .unwrap();
6562 assert!(!forge.calls().contains(&"merge"));
6563 assert_eq!(state.status, RunStatus::Blocked);
6564 let why = state.merge.as_ref().unwrap().detail.clone();
6565 assert!(why.contains("new") && why.contains("old"), "{why}");
6566 }
6567
6568 #[test]
6569 fn a_refusal_read_against_another_commits_checks_is_pending() {
6570 let mut after = seen("a", Checks::Green, "BLOCKED", false);
6571 after.rollup_head = "old".to_owned();
6572 assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
6573 }
6574
6575 #[tokio::test]
6576 async fn a_matching_head_with_red_non_required_checks_still_merges() {
6577 let mut state = landing_state();
6578 let forge = Scripted::new(
6579 vec![seen("a", Checks::Red, "CLEAN", false)],
6580 vec![(true, "")],
6581 );
6582 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6583 .await
6584 .unwrap();
6585 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6586 assert_eq!(state.status, RunStatus::Merged);
6587 }
6588
6589 #[tokio::test]
6590 async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
6591 let mut state = landing_state();
6592 let forge = Scripted::new(
6593 vec![
6594 seen("a", Checks::Green, "CLEAN", false),
6595 seen("a", Checks::Green, "CLEAN", false),
6596 seen("b", Checks::Green, "BLOCKED", false),
6598 seen("b", Checks::Green, "CLEAN", false),
6599 ],
6600 vec![(false, REFUSED), (true, "")],
6601 );
6602 land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6603 .await
6604 .unwrap();
6605 assert_eq!(
6606 forge.calls(),
6607 [
6608 "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
6609 ]
6610 );
6611 assert_eq!(state.status, RunStatus::Merged);
6612 }
6613
6614 fn merged_view(head: &str) -> Seen {
6615 let mut m = seen(head, Checks::Green, "CLEAN", false);
6616 m.pr.state = PrLifecycle::Merged;
6617 m
6618 }
6619
6620 fn has(argv: &[String], flag: &str) -> bool {
6621 argv.iter().any(|a| a == flag)
6622 }
6623
6624 fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
6625 let at = argv.iter().position(|a| a == flag)?;
6626 argv.get(at + 1).map(String::as_str)
6627 }
6628
6629 const URL: &str = "https://github.com/o/r/pull/42";
6630
6631 #[tokio::test]
6632 async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
6633 let mut state = landing_state();
6634 let forge = Scripted::new(
6635 vec![
6636 seen("abc", Checks::Green, "CLEAN", false),
6637 seen("abc", Checks::Green, "CLEAN", false),
6638 ],
6639 vec![(true, "")],
6640 );
6641 land_with(&mut state, URL, &forge).await.unwrap();
6642 assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
6643 let argv = &forge.argvs()[0];
6644 assert!(has(argv, "--squash") && has(argv, "--subject"));
6645 assert!(!has(argv, "--auto") && !has(argv, "--admin"));
6646 assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
6647 assert_eq!(state.status, RunStatus::Merged);
6648 assert!(state.land_armed_head.is_none());
6649 }
6650
6651 #[tokio::test]
6652 async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
6653 let mut state = landing_state();
6654 state.land_armed_head = Some("a".to_owned());
6655 let forge = Scripted::new(
6656 vec![seen("a", Checks::Green, "CLEAN", false)],
6657 vec![(true, ""), (true, "")],
6658 );
6659 land_with(&mut state, URL, &forge).await.unwrap();
6660 let argvs = forge.argvs();
6661 assert!(has(&argvs[0], "--disable-auto"));
6662 assert!(!has(&argvs[1], "--auto"));
6663 assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
6664 assert_eq!(state.status, RunStatus::Merged);
6665 assert!(state.land_armed_head.is_none());
6666 }
6667
6668 #[tokio::test]
6669 async fn an_approval_never_carries_over_to_a_new_head() {
6670 crate::run::pin_test_home();
6671 let mut state = run_state();
6672 state.config.graph.land_approval = true;
6673 let pr = green_pr();
6674 let store = ask::Questions::open();
6675
6676 approval_gate(&mut state, &pr, "feat: x", None, "aaa")
6677 .await
6678 .unwrap();
6679 let mut q = store
6680 .list()
6681 .into_iter()
6682 .find(|q| q.run == state.id)
6683 .unwrap();
6684 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6685 store.put(&mut q).unwrap();
6686 assert_eq!(
6687 approval_gate(&mut state, &pr, "feat: x", None, "AAA")
6688 .await
6689 .unwrap(),
6690 ApprovalGate::Approved,
6691 "the same head keeps its approval"
6692 );
6693
6694 assert_eq!(
6696 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6697 .await
6698 .unwrap(),
6699 ApprovalGate::Pending
6700 );
6701 let all: Vec<_> = store
6702 .list()
6703 .into_iter()
6704 .filter(|q| q.run == state.id)
6705 .collect();
6706 assert_eq!(all.len(), 2);
6707
6708 state.land_approval = None;
6710 assert_eq!(
6711 approval_gate(&mut state, &pr, "feat: x", None, "bbb")
6712 .await
6713 .unwrap(),
6714 ApprovalGate::Pending
6715 );
6716 let open = store
6717 .list()
6718 .into_iter()
6719 .filter(|q| q.run == state.id && q.status.open())
6720 .count();
6721 assert_eq!(open, 1, "the superseded question was retired");
6722 }
6723
6724 #[tokio::test]
6725 async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
6726 let mut state = landing_state();
6727 let forge = Scripted::new(
6728 vec![
6729 seen("a", Checks::Green, "CLEAN", false),
6730 seen("b", Checks::Green, "CLEAN", false),
6732 ],
6733 vec![(true, "")],
6734 );
6735 land_with(&mut state, URL, &forge).await.unwrap();
6736 let argvs = forge.argvs();
6737 assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
6738 assert!(!has(&argvs[0], "--auto"));
6739 assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
6740 assert_eq!(state.status, RunStatus::Merged);
6741 }
6742
6743 fn passing(label: &str) -> CheckInfo {
6744 CheckInfo {
6745 label: label.to_owned(),
6746 verdict: Verdict::Pass,
6747 required: Some(false),
6748 }
6749 }
6750
6751 fn names(xs: &[&str]) -> BTreeSet<String> {
6752 xs.iter().map(|x| (*x).to_owned()).collect()
6753 }
6754
6755 #[test]
6756 fn a_required_check_the_rollup_never_listed_is_named() {
6757 let req = names(&["build"]);
6758 let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
6759 assert!(why.contains("never reported: build"), "{why}");
6760 assert!(!why.contains("probably waiting for a review"), "{why}");
6761 }
6762
6763 #[test]
6764 fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
6765 let why = waiting_on("BLOCKED", &[passing("review")], None);
6766 assert!(why.contains("could not be read"), "{why}");
6767 assert!(!why.contains("probably waiting for a review"), "{why}");
6768 }
6769
6770 #[test]
6771 fn all_required_reported_keeps_the_review_guess() {
6772 let req = names(&["build"]);
6773 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6774 assert!(why.contains("probably waiting for a review"), "{why}");
6775 assert!(!why.contains("never reported"), "{why}");
6776 }
6777
6778 #[test]
6779 fn required_names_match_the_rollup_ignoring_case_only() {
6780 let req = names(&["Build"]);
6781 let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
6782 assert!(!why.contains("never reported"), "{why}");
6783 }
6784
6785 #[test]
6786 fn required_contexts_are_read_from_protection_and_rulesets() {
6787 let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
6788 assert_eq!(
6789 parse_classic_required(classic),
6790 Some(names(&["build", "lint"]))
6791 );
6792 let rules = r#"[{"type":"pull_request","parameters":{}},
6793 {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
6794 assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
6795 assert_eq!(parse_ruleset_required("nope"), None);
6796 assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
6797 }
6798
6799 #[test]
6800 fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
6801 let shown = BTreeSet::new();
6802 let ok = seen("a", Checks::Green, "CLEAN", false);
6803 let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
6804 assert!(guard(Some(&ok)));
6805 assert!(!guard(None));
6806 assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
6807 let mut stale = ok.clone();
6808 stale.rollup_head = "old".to_owned();
6809 assert!(!guard(Some(&stale)));
6810 assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
6811 assert!(!guard(Some(&merged_view("a"))));
6812 }
6813
6814 #[test]
6815 fn the_rollup_node_carries_whether_each_check_is_required() {
6816 let node = node_json("new", "SUCCESS", false)
6817 .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
6818 let s = seen_from(&view_json("new"), Some(&node)).unwrap();
6819 assert_eq!(s.contexts.len(), 1);
6820 assert_eq!(s.contexts[0].required, Some(true));
6821 let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
6822 assert_eq!(s.contexts[0].required, None);
6823 }
6824
6825 #[tokio::test]
6826 async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
6827 let mut state = landing_state();
6828 state.land_armed_head = Some("a".to_owned());
6829 let forge = Scripted::new(
6830 vec![seen("a", Checks::Green, "CLEAN", true)],
6831 vec![(false, "disable exploded")],
6832 );
6833 land_with(&mut state, URL, &forge).await.unwrap();
6834 assert!(!forge.calls().contains(&"fix"));
6835 assert_eq!(state.status, RunStatus::Blocked);
6836 assert_eq!(state.land_armed_head.as_deref(), Some("a"));
6837 let why = state.merge.as_ref().unwrap().detail.clone();
6838 assert!(why.contains("disable exploded"), "{why}");
6839 }
6840}