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, 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_subject(pr_title: &str, instruction: &str) -> String {
430 let title = pr_title.trim();
431 if !title.is_empty() && !title.starts_with("magi: candidate") {
432 return title.to_owned();
433 }
434 let first = instruction
435 .lines()
436 .map(str::trim)
437 .find(|l| !l.is_empty())
438 .unwrap_or("magi: land the winning candidate");
439 first.trim_start_matches(['#', ' ']).to_owned()
440}
441
442pub const APPROVE: &str = "merge";
444
445pub const HOLD: &str = "hold";
447
448pub const APPROVAL_NODE: &str = "land-approval";
454
455pub const DIFF_MAX_LINES: usize = 400;
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum Approval {
467 Merge,
469 Hold,
471}
472
473pub fn approval(answer: Option<&str>) -> Approval {
481 match answer {
482 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
483 _ => Approval::Hold,
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489enum ApprovalGate {
490 Approved,
492 Held,
495 Pending,
497}
498
499fn esc(s: &str) -> String {
509 let mut out = String::with_capacity(s.len());
510 for c in s.chars() {
511 match c {
512 '&' => out.push_str("&"),
513 '<' => out.push_str("<"),
514 '>' => out.push_str(">"),
515 '"' => out.push_str("""),
516 '\'' => out.push_str("'"),
517 _ => out.push(c),
518 }
519 }
520 out
521}
522
523#[derive(Debug, Clone, PartialEq, Eq)]
525struct StatRow {
526 path: String,
527 added: Option<u64>,
529 removed: Option<u64>,
530}
531
532impl StatRow {
533 fn churn(&self) -> u64 {
536 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
537 }
538}
539
540fn parse_numstat(numstat: &str) -> Vec<StatRow> {
546 let mut rows: Vec<StatRow> = numstat
547 .lines()
548 .filter_map(|line| {
549 let mut parts = line.splitn(3, '\t');
550 let added = parts.next()?.trim();
551 let removed = parts.next()?.trim();
552 let path = parts.next()?.trim();
553 if path.is_empty() {
554 return None;
555 }
556 Some(StatRow {
557 path: path.to_owned(),
558 added: added.parse().ok(),
559 removed: removed.parse().ok(),
560 })
561 })
562 .collect();
563 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
566 rows
567}
568
569fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
578 if line.starts_with("+++") || line.starts_with("---") {
579 (" ", "color:#57606a;font-weight:600", line)
580 } else if let Some(body) = line.strip_prefix('+') {
581 ("+", "background:#e6ffec;color:#0a3622", body)
582 } else if let Some(body) = line.strip_prefix('-') {
583 ("-", "background:#ffebe9;color:#5c1a17", body)
584 } else if line.starts_with("@@") {
585 ("~", "background:#eef2ff;color:#3730a3", line)
586 } else if let Some(body) = line.strip_prefix(' ') {
587 (" ", "", body)
588 } else {
589 (" ", "color:#57606a;font-weight:600", line)
590 }
591}
592
593struct Words {
602 html_lang: &'static str,
603 task: &'static str,
604 what_changed: &'static str,
605 review_verdict: &'static str,
606 reviewer: &'static str,
607 reviewer_no_answer: &'static str,
608 checks: &'static str,
609 nothing_failing: &'static str,
610 files_changed: &'static str,
611 commits: &'static str,
612 no_commits: &'static str,
613 comments: &'static str,
614 no_comments: &'static str,
615 diff: &'static str,
616 truncated: &'static str,
617 lands_as: &'static str,
618}
619
620const EN: Words = Words {
621 html_lang: "en",
622 task: "Task",
623 what_changed: "What changed",
624 review_verdict: "Review verdict",
625 reviewer: "Reviewer",
626 reviewer_no_answer: "produced no answer",
627 checks: "Checks",
628 nothing_failing: "Nothing failing.",
629 files_changed: "file(s) changed",
630 commits: "Commits being squashed",
631 no_commits: "No commit subjects could be read from the branch.",
632 comments: "Review comments",
633 no_comments: "Nothing outstanding at this observation.",
634 diff: "Diff",
635 truncated: "Truncated",
636 lands_as: "They land as one commit titled",
637};
638
639const JA: Words = Words {
640 html_lang: "ja",
641 task: "タスク",
642 what_changed: "変更内容",
643 review_verdict: "レビューの結論",
644 reviewer: "レビュアー",
645 reviewer_no_answer: "回答なし",
646 checks: "チェック",
647 nothing_failing: "失敗しているものはありません。",
648 files_changed: "ファイル変更",
649 commits: "squash されるコミット",
650 no_commits: "ブランチからコミット件名を読めませんでした。",
651 comments: "レビューコメント",
652 no_comments: "この時点で未対応のものはありません。",
653 diff: "差分",
654 truncated: "省略",
655 lands_as: "これらは次の件名の1コミットとして入ります:",
656};
657
658impl Words {
659 fn lands_as_tail(&self) -> &'static str {
663 if self.html_lang == "ja" {
664 "。この件名も承認の対象です。"
665 } else {
666 ", which you are approving too."
667 }
668 }
669
670 fn approval_summary(&self, number: u64, subject: &str) -> String {
672 if self.html_lang == "ja" {
673 format!("プルリクエスト #{number} をマージ: {subject}")
674 } else {
675 format!("merge pull request #{number}: {subject}")
676 }
677 }
678
679 fn approval_detail(
681 &self,
682 url: &str,
683 base: &str,
684 subject: &str,
685 contested: Option<&ContestedHandoff>,
686 ) -> String {
687 let body = if self.html_lang == "ja" {
688 format!(
689 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
690 できる状態です。差分の要約・パッチ・squash されるコミットは\
691 下のパネルにあります。"
692 )
693 } else {
694 format!(
695 "{url} is green and ready to squash into `{base}` as `{subject}`. \
696 The panel holds the diffstat, the patch and the commits being squashed."
697 )
698 };
699 match contested {
700 Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
701 None => body,
702 }
703 }
704
705 fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
708 const SHOWN: usize = 5;
709 const TITLE_CHARS: usize = 100;
710 let ja = self.html_lang == "ja";
711 let mut out = if ja {
712 format!(
713 "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
714 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
715 )
716 } else {
717 format!(
718 "{url} is held for approval although merge approvals are off: the \
719 review ended with blocking findings still open and a reviewer \
720 voting reject.\n"
721 )
722 };
723 for f in c.findings.iter().take(SHOWN) {
724 let at = match (&f.file, f.line) {
725 (Some(file), Some(line)) => format!("{file}:{line}"),
726 (Some(file), None) => file.clone(),
727 _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
728 };
729 let title: String = f.title.chars().take(TITLE_CHARS).collect();
730 let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
731 }
732 if c.findings.len() > SHOWN {
733 let more = c.findings.len() - SHOWN;
734 let _ = writeln!(
735 out,
736 "{}",
737 if ja {
738 format!("- ほか {more} 件")
739 } else {
740 format!("- and {more} more")
741 }
742 );
743 }
744 let seats: Vec<String> = c
745 .rejecters
746 .iter()
747 .map(|(seat, agent)| format!("#{seat} ({agent})"))
748 .collect();
749 let _ = write!(
750 out,
751 "{} {}",
752 if ja {
753 "却下したレビュアー:"
754 } else {
755 "Rejected by reviewer:"
756 },
757 seats.join(", ")
758 );
759 out
760 }
761
762 fn truncated_note(
764 &self,
765 omitted: usize,
766 total: usize,
767 shown: usize,
768 where_: &str,
769 base: &str,
770 head: &str,
771 ) -> String {
772 if self.html_lang == "ja" {
773 format!(
774 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
775 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
776 プルリクエストにあります。"
777 )
778 } else {
779 format!(
780 "{omitted} of {total} diff lines omitted after the first {shown}. \
781 The whole patch is in <code>{where_}</code> \
782 (<code>git diff {base}...{head}</code>) and on the pull request."
783 )
784 }
785 }
786}
787
788fn words(language: &str) -> &'static Words {
791 if crate::lang::is_japanese(language) {
792 &JA
793 } else {
794 &EN
795 }
796}
797
798pub fn approval_panel(
810 state: &RunState,
811 pr: &PrState,
812 diffstat: &str,
813 diff: &str,
814 commits: &[String],
815 subject: &str,
816) -> String {
817 let rows = parse_numstat(diffstat);
818 let w = words(&state.config.graph.language);
819 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
820
821 let _ = writeln!(
822 h,
823 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
824 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
825 w.html_lang
826 );
827 let _ = writeln!(
828 h,
829 "<title>merge #{} — {}</title>\n</head>",
830 pr.number,
831 esc(subject)
832 );
833 h.push_str(
834 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
835 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
836 word-break:break-word\">\n",
837 );
838
839 let _ = writeln!(
841 h,
842 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
843 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
844 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
845 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
846 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
847 pr.number,
848 esc(&state.base_branch),
849 esc(subject),
850 esc(&state.id),
851 esc(&pr.url),
852 esc(&pr.url),
853 );
854
855 let _ = writeln!(
858 h,
859 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
860 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
861 w.task,
862 esc(&state.instruction)
863 );
864
865 if let Some(summary) = state
867 .winner()
868 .map(|c| c.summary.as_str())
869 .filter(|s| !s.is_empty())
870 {
871 let _ = writeln!(
872 h,
873 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
874 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
875 w.what_changed,
876 esc(summary)
877 );
878 }
879
880 if let Some(round) = state.reviews.last() {
883 let _ = writeln!(
884 h,
885 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
886 w.review_verdict
887 );
888 for r in &round.reviews {
889 let body = match &r.failed {
896 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
897 None => esc(&r.summary),
898 };
899 let _ = writeln!(
900 h,
901 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
902 border-radius:6px\">\
903 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
904 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
905 w.reviewer,
906 r.reviewer,
907 esc(&r.agent),
908 body,
909 );
910 }
911 }
912
913 let _ = writeln!(
914 h,
915 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
916 w.checks,
917 esc(pr.checks.as_str())
918 );
919 if pr.failing.is_empty() {
920 let _ = writeln!(
921 h,
922 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
923 w.nothing_failing
924 );
925 } else {
926 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
927 for f in &pr.failing {
928 let _ = writeln!(h, "<li>{}</li>", esc(f));
929 }
930 h.push_str("</ul>\n");
931 }
932
933 let _ = writeln!(
936 h,
937 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
938 rows.len(),
939 w.files_changed
940 );
941 h.push_str(
942 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
943 <thead><tr>\
944 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
945 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
946 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
947 </th></tr></thead>\n<tbody>\n",
948 );
949 let mut total_added = 0u64;
950 let mut total_removed = 0u64;
951 for r in &rows {
952 total_added += r.added.unwrap_or(0);
953 total_removed += r.removed.unwrap_or(0);
954 let cell = |n: Option<u64>| match n {
955 Some(n) => n.to_string(),
956 None => "bin".to_owned(),
957 };
958 let _ = writeln!(
959 h,
960 "<tr>\
961 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
962 font-family:ui-monospace,monospace\">{}</td>\
963 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
964 color:#0a3622\">{}</td>\
965 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
966 color:#5c1a17\">{}</td></tr>",
967 esc(&r.path),
968 cell(r.added),
969 cell(r.removed),
970 );
971 }
972 let _ = writeln!(
973 h,
974 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
975 <td style=\"padding:4px 2px\">total</td>\
976 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
977 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
978 </tr></tfoot>\n</table>"
979 );
980
981 let _ = writeln!(
983 h,
984 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
985 w.commits
986 );
987 if commits.is_empty() {
988 h.push_str(&format!(
989 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
990 w.no_commits
991 ));
992 } else {
993 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
994 for c in commits {
995 let _ = writeln!(h, "<li>{}</li>", esc(c));
996 }
997 h.push_str("</ol>\n");
998 }
999 let _ = writeln!(
1000 h,
1001 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1002 w.lands_as,
1003 esc(subject),
1004 w.lands_as_tail()
1005 );
1006
1007 let _ = writeln!(
1009 h,
1010 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1011 w.comments
1012 );
1013 if pr.review_comments.is_empty() {
1014 h.push_str(&format!(
1015 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1016 w.no_comments
1017 ));
1018 } else {
1019 for c in &pr.review_comments {
1020 let anchor = match (&c.path, c.line) {
1021 (Some(p), Some(l)) => format!("{p}:{l}"),
1022 (Some(p), None) => p.clone(),
1023 _ => "pull request thread".to_owned(),
1024 };
1025 let _ = writeln!(
1026 h,
1027 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1028 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1029 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1030 esc(&c.author),
1031 esc(&anchor),
1032 esc(&tail(&c.body, 800)),
1033 );
1034 }
1035 }
1036
1037 let total = diff.lines().count();
1039 let shown = total.min(DIFF_MAX_LINES);
1040 let _ = writeln!(
1041 h,
1042 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1043 w.diff
1044 );
1045 h.push_str(
1046 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1047 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1048 );
1049 for line in diff.lines().take(shown) {
1050 let (gutter, style, body) = diff_row(line);
1051 let _ = writeln!(
1052 h,
1053 "<div style=\"display:flex;{style}\">\
1054 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1055 border-right:1px solid #d0d7de\">{gutter}</span>\
1056 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1057 esc(body),
1058 );
1059 }
1060 h.push_str("</div>\n");
1061 if total > shown {
1062 let omitted = total - shown;
1063 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1064 let where_ = state.winner().map_or_else(
1065 || state.repo.display().to_string(),
1066 |w| w.worktree.display().to_string(),
1067 );
1068 let _ = writeln!(
1069 h,
1070 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1071 font-size:13px\">{}: {}</p>",
1072 w.truncated,
1073 w.truncated_note(
1074 omitted,
1075 total,
1076 shown,
1077 &esc(&where_),
1078 &esc(&state.base_branch),
1079 &esc(head),
1080 ),
1081 );
1082 }
1083
1084 h.push_str("</body>\n</html>\n");
1085 h
1086}
1087
1088fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1092 if state.config.graph.hold_contested_merge {
1093 state.contested_handoff.clone()
1094 } else {
1095 None
1096 }
1097}
1098
1099async fn approval_gate(
1119 state: &mut RunState,
1120 pr: &PrState,
1121 subject: &str,
1122 contested: Option<&ContestedHandoff>,
1123) -> Result<ApprovalGate> {
1124 let store = ask::Questions::open();
1125 let existing = store
1126 .list()
1127 .into_iter()
1128 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
1129 .max_by(|a, b| a.id.cmp(&b.id));
1130
1131 let q = match existing {
1132 Some(q) => q,
1133 None => {
1134 let (worktree, head) = match state.winner() {
1135 Some(w) => (w.worktree.clone(), w.branch.clone()),
1136 None => (state.repo.clone(), "HEAD".to_owned()),
1137 };
1138 let base = state.base_branch.clone();
1139 let range = format!("{base}...{head}");
1140 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1144 .await
1145 .map(|o| o.stdout)
1146 .unwrap_or_default();
1147 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1148 let commits: Vec<String> = git::git_raw(
1149 &worktree,
1150 &[
1151 "log",
1152 "--reverse",
1153 "--format=%s",
1154 &format!("{base}..{head}"),
1155 ],
1156 )
1157 .await
1158 .map(|o| o.stdout)
1159 .unwrap_or_default()
1160 .lines()
1161 .filter(|l| !l.trim().is_empty())
1162 .map(str::to_owned)
1163 .collect();
1164
1165 let w = words(&state.config.graph.language);
1166 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1167 let mut fresh = ask::Question::new(
1168 state.id.clone(),
1169 APPROVAL_NODE.to_owned(),
1170 "land".to_owned(),
1171 w.approval_summary(pr.number, subject),
1172 w.approval_detail(&pr.url, &base, subject, contested),
1173 vec![APPROVE.to_owned(), HOLD.to_owned()],
1174 );
1175 store
1176 .put_panel(&mut fresh, &html, &[])
1177 .context("write the merge approval panel")?;
1178 store
1179 .put(&mut fresh)
1180 .context("file the merge approval question")?;
1181 state.event(
1182 "land",
1183 format!("asking for merge approval ({})", fresh.short()),
1184 );
1185 state.save()?;
1186 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1187 tracing::warn!(
1191 "could not notify about merge approval question {}: {e:#} - \
1192 the web UI is the only surface for it now",
1193 fresh.short()
1194 );
1195 }
1196 fresh
1197 }
1198 };
1199
1200 Ok(match q.status {
1201 ask::QuestionStatus::Open => ApprovalGate::Pending,
1202 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1206 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1210 Approval::Merge => ApprovalGate::Approved,
1211 Approval::Hold => ApprovalGate::Held,
1212 },
1213 })
1214}
1215
1216pub fn parse_pr(json: &str) -> Result<PrState> {
1219 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1220 let state = match raw.state.to_ascii_uppercase().as_str() {
1221 "OPEN" => PrLifecycle::Open,
1222 "MERGED" => PrLifecycle::Merged,
1223 "CLOSED" => PrLifecycle::Closed,
1224 other => bail!("unknown pull request state `{other}`"),
1225 };
1226
1227 let mut failing = Vec::new();
1228 let mut pending = false;
1229 let mut unknown = false;
1230 for check in &raw.status_check_rollup {
1231 match check.verdict() {
1232 Verdict::Pass => {}
1233 Verdict::Pending => pending = true,
1234 Verdict::Fail => failing.push(check.label()),
1235 Verdict::Unknown => unknown = true,
1236 }
1237 }
1238 let checks = if raw.status_check_rollup.is_empty() {
1239 Checks::Unknown
1240 } else if pending {
1241 Checks::Pending
1242 } else if !failing.is_empty() {
1243 Checks::Red
1244 } else if unknown {
1245 Checks::Unknown
1246 } else {
1247 Checks::Green
1248 };
1249
1250 let mut review_comments = Vec::new();
1251 for r in raw.reviews {
1252 push_if_outstanding(
1253 &mut review_comments,
1254 ReviewComment {
1255 author: r.author.login,
1256 path: None,
1257 line: None,
1258 body: r.body,
1259 },
1260 );
1261 }
1262 for c in raw.comments {
1263 push_if_outstanding(
1264 &mut review_comments,
1265 ReviewComment {
1266 author: c.author.login,
1267 path: None,
1268 line: None,
1269 body: c.body,
1270 },
1271 );
1272 }
1273
1274 Ok(PrState {
1275 url: raw.url,
1276 number: raw.number,
1277 state,
1278 checks,
1279 failing,
1280 review_comments,
1281 blocking: Blocking::of(&raw.merge_state_status),
1282 })
1283}
1284
1285pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1295 let view = gh(
1296 repo,
1297 &[
1298 "pr".to_owned(),
1299 "view".to_owned(),
1300 pr_url.to_owned(),
1301 "--json".to_owned(),
1302 "state".to_owned(),
1303 ],
1304 )
1305 .await?;
1306 if !view.0 {
1307 bail!("gh pr view {pr_url}: {}", view.1);
1308 }
1309 Ok(parse_pr(&view.1)?.state)
1313}
1314
1315#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1319pub struct ExternalMerge {
1320 pub url: String,
1322 pub number: u64,
1324}
1325
1326#[derive(Debug, Deserialize)]
1327#[serde(rename_all = "camelCase")]
1328struct GhMergedPr {
1329 url: String,
1330 number: u64,
1331 merged_at: String,
1332 base_ref_name: String,
1333}
1334
1335fn pick_merged_pr(
1349 json: &str,
1350 base_branch: &str,
1351 created_at: Timestamp,
1352) -> Result<Option<ExternalMerge>> {
1353 let raw: Vec<GhMergedPr> =
1354 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1355 let mut matches: Vec<ExternalMerge> = Vec::new();
1356 for pr in raw {
1357 if pr.base_ref_name != base_branch {
1358 continue;
1359 }
1360 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1361 continue;
1362 };
1363 if merged_at < created_at {
1364 continue;
1365 }
1366 matches.push(ExternalMerge {
1367 url: pr.url,
1368 number: pr.number,
1369 });
1370 }
1371 if matches.len() == 1 {
1372 Ok(matches.pop())
1373 } else {
1374 Ok(None)
1375 }
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Eq)]
1380pub enum OpenPr {
1381 None,
1383 One {
1385 url: String,
1387 title: String,
1389 },
1390 Many(Vec<String>),
1392}
1393
1394#[derive(Debug, Deserialize)]
1395#[serde(rename_all = "camelCase")]
1396struct GhOpenPr {
1397 url: String,
1401 #[serde(default)]
1402 title: String,
1403 base_ref_name: String,
1404}
1405
1406pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1412 let raw: Vec<GhOpenPr> =
1413 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1414 let mut hits: Vec<GhOpenPr> = raw
1415 .into_iter()
1416 .filter(|p| p.base_ref_name == base)
1417 .collect();
1418 Ok(match hits.len() {
1419 0 => OpenPr::None,
1420 1 => {
1421 let p = hits.remove(0);
1422 OpenPr::One {
1423 url: p.url,
1424 title: p.title,
1425 }
1426 }
1427 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1428 })
1429}
1430
1431pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1435 let (ok, out) = gh(
1436 repo,
1437 &[
1438 "pr".to_owned(),
1439 "list".to_owned(),
1440 "--head".to_owned(),
1441 branch.to_owned(),
1442 "--base".to_owned(),
1443 base.to_owned(),
1444 "--state".to_owned(),
1445 "open".to_owned(),
1446 "--json".to_owned(),
1447 "number,url,title,baseRefName".to_owned(),
1448 ],
1449 )
1450 .await?;
1451 if !ok {
1452 bail!("gh pr list failed: {out}");
1453 }
1454 pick_open_pr(&out, base)
1455}
1456
1457#[derive(Debug, Deserialize)]
1458#[serde(rename_all = "camelCase")]
1459struct GhPrHead {
1460 head_ref_name: String,
1461 base_ref_name: String,
1462 state: String,
1463 is_cross_repository: bool,
1466 head_ref_oid: String,
1469}
1470
1471#[derive(Debug, Clone, PartialEq, Eq)]
1473pub struct Refusal {
1474 pub retry: bool,
1477 pub why: String,
1479}
1480
1481impl Refusal {
1482 fn final_(why: String) -> Self {
1483 Self { retry: false, why }
1484 }
1485}
1486
1487pub fn closable(
1496 json: &str,
1497 branch: &str,
1498 base: &str,
1499 verified: &[String],
1500) -> std::result::Result<(), Refusal> {
1501 let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
1502 retry: true,
1503 why: format!("could not read the pull request ({e})"),
1504 })?;
1505 if pr.head_ref_name != branch {
1506 return Err(Refusal::final_(format!(
1507 "its head is `{}`, not this run's `{branch}`",
1508 pr.head_ref_name
1509 )));
1510 }
1511 if pr.is_cross_repository {
1512 return Err(Refusal::final_("its head lives in a fork".to_owned()));
1513 }
1514 if pr.base_ref_name != base {
1515 return Err(Refusal::final_(format!(
1516 "it targets `{}`, not `{base}`",
1517 pr.base_ref_name
1518 )));
1519 }
1520 if !pr.state.eq_ignore_ascii_case("open") {
1521 return Err(Refusal::final_(format!(
1522 "it is already {}",
1523 pr.state.to_ascii_lowercase()
1524 )));
1525 }
1526 if !verified.contains(&pr.head_ref_oid) {
1530 return Err(Refusal {
1531 retry: true,
1532 why: format!(
1533 "its head {} is not a commit this run checked against the base",
1534 crate::already::short_sha(&pr.head_ref_oid)
1535 ),
1536 });
1537 }
1538 Ok(())
1539}
1540
1541async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
1546 let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
1547 return false;
1548 };
1549 is_forge_url(url.trim())
1550}
1551
1552fn is_forge_url(url: &str) -> bool {
1553 url.contains("://") && !url.starts_with("file://")
1554 || url
1555 .split_once(':')
1556 .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
1557}
1558
1559fn forge_unavailable(message: &str) -> bool {
1562 message.contains("known GitHub host") || message.contains("spawn gh")
1563}
1564
1565pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
1568 let how = match evidence.proof {
1569 crate::already::Proof::PatchId => format!(
1570 "carried by commit {} on `{base}` with the same patch",
1571 evidence.names()
1572 ),
1573 crate::already::Proof::Ancestry => {
1574 format!("already in the history of `{base}` as {}", evidence.names())
1575 }
1576 crate::already::Proof::Tree => format!(
1577 "already part of `{base}` (merging this branch changes nothing at {})",
1578 crate::already::short_sha(&evidence.tip)
1579 ),
1580 };
1581 format!(
1582 "Closing: everything this branch adds is {how}, so there is nothing left to \
1583 land. This pull request was closed automatically after that was verified; \
1584 reopen it if you disagree."
1585 )
1586}
1587
1588pub async fn close_superseded_pr(
1600 state: &mut RunState,
1601 branch: &str,
1602 evidence: &crate::already::Evidence,
1603 verified: &[String],
1604) -> Result<std::result::Result<String, String>> {
1605 let repo = state.repo.clone();
1606 let base = state.base_branch.clone();
1607 let url = match state.pr.as_ref().filter(|p| p.state == "open") {
1608 Some(p) => p.url.clone(),
1609 None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
1614 return Ok(Err(
1615 "the remote is not a forge, so there is no pull request".to_owned(),
1616 ));
1617 }
1618 None => match find_open_pr(&repo, branch, &base).await {
1619 Err(e) if forge_unavailable(&format!("{e:#}")) => {
1620 return Ok(Err(format!("no forge to ask: {e:#}")));
1621 }
1622 Err(e) => return Err(e),
1623 Ok(OpenPr::One { url, .. }) => url,
1624 Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
1625 Ok(OpenPr::Many(urls)) => {
1626 return Ok(Err(format!(
1627 "{} open pull requests name it; not choosing between them",
1628 urls.len()
1629 )));
1630 }
1631 },
1632 };
1633 let (ok, view) = gh(
1634 &repo,
1635 &[
1636 "pr".to_owned(),
1637 "view".to_owned(),
1638 url.clone(),
1639 "--json".to_owned(),
1640 "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
1641 ],
1642 )
1643 .await?;
1644 if !ok {
1645 bail!("gh pr view {url} failed: {view}");
1646 }
1647 if let Err(refusal) = closable(&view, branch, &base, verified) {
1648 if refusal.retry {
1651 bail!("left {url} open: {}", refusal.why);
1652 }
1653 return Ok(Err(format!("left {url} open: {}", refusal.why)));
1654 }
1655 let (ok, out) = gh(
1656 &repo,
1657 &[
1658 "pr".to_owned(),
1659 "close".to_owned(),
1660 url.clone(),
1661 "--comment".to_owned(),
1662 superseded_comment(&base, evidence),
1663 ],
1664 )
1665 .await?;
1666 if !ok {
1667 bail!("gh pr close {url} failed: {out}");
1668 }
1669 if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
1670 p.state = "closed".to_owned();
1671 }
1672 Ok(Ok(url))
1673}
1674
1675pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
1679 let (ok, out) = gh(
1680 repo,
1681 &[
1682 "pr".to_owned(),
1683 "edit".to_owned(),
1684 url.to_owned(),
1685 "--title".to_owned(),
1686 title.to_owned(),
1687 ],
1688 )
1689 .await?;
1690 if !ok {
1691 bail!("gh pr edit failed: {out}");
1692 }
1693 Ok(())
1694}
1695
1696pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1709 let Some(winner) = state.winner() else {
1710 return Ok(None);
1711 };
1712 let branch = winner.branch.clone();
1713 let out = gh(
1714 &state.repo,
1715 &[
1716 "pr".to_owned(),
1717 "list".to_owned(),
1718 "--head".to_owned(),
1719 branch.clone(),
1720 "--state".to_owned(),
1721 "merged".to_owned(),
1722 "--json".to_owned(),
1723 "url,number,mergedAt,baseRefName".to_owned(),
1724 ],
1725 )
1726 .await?;
1727 if !out.0 {
1728 bail!("gh pr list --head {branch}: {}", out.1);
1729 }
1730 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1731}
1732
1733pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1745 let out = tokio::process::Command::new("git")
1746 .args(["merge-base", "--is-ancestor", branch, base_branch])
1747 .current_dir(repo)
1748 .quiet()
1749 .stdin(std::process::Stdio::null())
1750 .output()
1751 .await
1752 .context("spawn git merge-base --is-ancestor")?;
1753 Ok(out.status.success())
1754}
1755
1756fn forge_slug(url: &str) -> Option<(String, &str)> {
1764 let rest = url.rsplit("://").next()?;
1765 let (host, path) = rest.split_once('/')?;
1766 if host.is_empty() {
1767 return None;
1768 }
1769 Some((host.to_ascii_lowercase(), path))
1770}
1771
1772pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
1780 let (host, path) = forge_slug(url)?;
1781 let mut segments = path.split('/');
1782 let owner = segments.next()?;
1783 let repo = segments.next()?;
1784 let kind = segments.next()?;
1785 if owner.is_empty() || repo.is_empty() || kind != "pull" {
1786 return None;
1787 }
1788 Some(format!("{host}/{owner}/{repo}"))
1789}
1790
1791fn slug_of_repo_url(url: &str) -> Option<String> {
1795 let (host, path) = forge_slug(url)?;
1796 let mut segments = path.split('/');
1797 let owner = segments.next()?;
1798 let repo = segments.next()?;
1799 if owner.is_empty() || repo.is_empty() {
1800 return None;
1801 }
1802 Some(format!("{host}/{owner}/{repo}"))
1803}
1804
1805pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
1826 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
1827 return Ok(());
1828 }
1829 bail!(
1830 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
1831 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
1832 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
1833 verify by hand before treating this as a false positive)"
1834 );
1835}
1836
1837async fn repo_slug(repo: &Path) -> Result<String> {
1848 let out = gh(
1849 repo,
1850 &[
1851 "repo".to_owned(),
1852 "view".to_owned(),
1853 "--json".to_owned(),
1854 "url".to_owned(),
1855 ],
1856 )
1857 .await?;
1858 if !out.0 {
1859 bail!("gh repo view --json url: {}", out.1);
1860 }
1861 #[derive(Debug, Deserialize)]
1862 struct GhRepo {
1863 url: String,
1864 }
1865 let parsed: GhRepo = serde_json::from_str(&out.1)
1866 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
1867 slug_of_repo_url(&parsed.url)
1868 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
1869}
1870
1871pub async fn correct_manual_merge(
1919 state: &mut RunState,
1920 url: &str,
1921) -> Result<(RunStatus, RunStatus)> {
1922 let Some(pr_slug) = slug_of_pr_url(url) else {
1923 bail!(
1924 "could not parse an owner/repo out of {url}; refusing to guess which repository \
1925 this pull request belongs to"
1926 );
1927 };
1928 let run_slug = repo_slug(&state.repo).await?;
1929 ensure_same_repo(&run_slug, &pr_slug)?;
1930 correct_merge(state, url).await
1931}
1932
1933pub(crate) async fn correct_confirmed_external_merge(
1944 state: &mut RunState,
1945 url: &str,
1946) -> Result<(RunStatus, RunStatus)> {
1947 correct_merge(state, url).await
1948}
1949
1950fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
1952 let Some(pr) = a.pr.as_ref() else {
1953 return false;
1954 };
1955 if !url.is_empty()
1956 && pr
1957 .url
1958 .trim_end_matches('/')
1959 .eq_ignore_ascii_case(url.trim_end_matches('/'))
1960 {
1961 return true;
1962 }
1963 number > 0
1964 && pr.number == number
1965 && match (a.repo.canonicalize(), repo.canonicalize()) {
1966 (Ok(x), Ok(y)) => x == y,
1967 _ => a.repo == repo,
1968 }
1969}
1970
1971fn rewrite_open_prs(
1984 home: &Path,
1985 decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
1986) -> usize {
1987 let now = Timestamp::now();
1988 let mut changed = 0;
1989 for id in crate::run::list_ids_in(&home.join("runs")) {
1990 let path = home.join("runs").join(&id).join("run.json");
1991 let Ok(body) = std::fs::read_to_string(&path) else {
1992 continue;
1993 };
1994 let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
1995 continue;
1996 };
1997 if !state.status.done()
1998 || state.pr.as_ref().is_none_or(|p| p.state != "open")
1999 || crate::daemon::is_working_on(home, &id, now)
2000 {
2001 continue;
2002 }
2003 let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2004 continue;
2005 };
2006 if let Some(pr) = state.pr.as_mut() {
2007 pr.state = to.as_str().to_owned();
2008 }
2009 let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2010 state.event(
2011 "land",
2012 format!("recorded {url} as {}: another run settled it", to.as_str()),
2013 );
2014 match state.save_under(home) {
2015 Ok(()) => changed += 1,
2016 Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2017 }
2018 }
2019 changed
2020}
2021
2022pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2027 if to == PrLifecycle::Open {
2028 return;
2029 }
2030 let Some(home) = crate::run::try_home() else {
2031 return;
2032 };
2033 write_pr_state_through_in(&home, run, to);
2034}
2035
2036pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2037 let Some(pr) = run.pr.as_ref() else {
2038 return 0;
2039 };
2040 let (url, number) = (pr.url.clone(), pr.number);
2041 rewrite_open_prs(home, &mut |other| {
2042 (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2043 })
2044}
2045
2046pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2049 let now = Timestamp::now();
2050 let mut out = Vec::new();
2051 for id in crate::run::list_ids_in(&home.join("runs")) {
2052 let path = home.join("runs").join(&id).join("run.json");
2053 let Ok(body) = std::fs::read_to_string(&path) else {
2054 continue;
2055 };
2056 let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2057 continue;
2058 };
2059 if let Some(pr) = state.pr.as_ref()
2060 && state.status.done()
2061 && pr.state == "open"
2062 && !pr.url.is_empty()
2063 && !crate::daemon::is_working_on(home, &id, now)
2064 {
2065 out.push((id, state.repo.clone(), pr.url.clone()));
2066 }
2067 }
2068 out
2069}
2070
2071pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2074 rewrite_open_prs(home, &mut |s| {
2075 s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2076 })
2077}
2078
2079pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2086 let mut known = BTreeMap::new();
2087 let mut failed = 0;
2088 let mut seen = BTreeSet::new();
2089 for (_, repo, url) in stale_open_prs(home) {
2090 if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2091 continue;
2092 }
2093 match lifecycle(&repo, &url).await {
2094 Ok(state) => {
2095 known.insert(url, state);
2096 }
2097 Err(e) => {
2098 tracing::warn!("repair pr state of {url}: {e:#}");
2099 failed += 1;
2100 }
2101 }
2102 }
2103 (apply_pr_states(home, &known), failed)
2104}
2105
2106async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2107 match lifecycle(&state.repo, url).await? {
2108 PrLifecycle::Merged => {}
2109 other => bail!(
2110 "{url} is {}, not merged; refusing to record {} as merged on a guess",
2111 other.as_str(),
2112 state.id
2113 ),
2114 }
2115 let before = state.status;
2116 if let Err(e) = land(state, url).await {
2117 state.status = RunStatus::Blocked;
2124 state.event("fold", format!("manual-merge correction failed: {e:#}"));
2125 state.save()?;
2126 return Err(e).context(format!("confirming the merge of {url}"));
2127 }
2128 state.event(
2129 "fold",
2130 "operator recorded this pull request as a manual merge; this run never \
2131 re-entered `land`, so `bump::after_merge` did not run for it - a release \
2132 bump this change might warrant has to be filed by hand",
2133 );
2134 if state.status == RunStatus::Merged {
2137 crate::followup::after_merge(state, url).await;
2138 }
2139 state.save()?;
2140 Ok((before, state.status))
2141}
2142
2143pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2150 let raw: Vec<GhInline> =
2151 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2152 let mut out = Vec::new();
2153 for c in raw {
2154 push_if_outstanding(
2155 &mut out,
2156 ReviewComment {
2157 author: c.user.login,
2158 path: c.path,
2159 line: c.line,
2160 body: c.body,
2161 },
2162 );
2163 }
2164 Ok(out)
2165}
2166
2167fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2173 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2174 return;
2175 }
2176 if comment.path.is_none() && is_noise(&comment.body) {
2177 return;
2178 }
2179 out.push(comment);
2180}
2181
2182pub fn is_noise(body: &str) -> bool {
2200 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2201 return true;
2202 }
2203 let mut content = false;
2204 for line in strip_blocks(body).lines() {
2205 let line = unquote(line);
2206 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2207 continue;
2208 }
2209 content = true;
2210 break;
2211 }
2212 !content
2213}
2214
2215fn strip_blocks(body: &str) -> String {
2217 let mut out = String::with_capacity(body.len());
2218 let mut rest = body;
2219 loop {
2220 let open = ["<!--", "<details>"]
2221 .iter()
2222 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2223 .min_by_key(|(i, _)| *i);
2224 let Some((at, tag)) = open else {
2225 out.push_str(rest);
2226 return out;
2227 };
2228 out.push_str(&rest[..at]);
2229 let after = &rest[at + tag.len()..];
2230 let close = if tag == "<!--" { "-->" } else { "</details>" };
2231 match after.find(close) {
2232 Some(end) => rest = &after[end + close.len()..],
2233 None => return out,
2235 }
2236 }
2237}
2238
2239fn unquote(line: &str) -> &str {
2241 let mut s = line.trim();
2242 while let Some(rest) = s.strip_prefix('>') {
2243 s = rest.trim_start();
2244 }
2245 s.trim()
2246}
2247
2248fn is_checklist(line: &str) -> bool {
2250 let rest = line
2251 .strip_prefix("- ")
2252 .or_else(|| line.strip_prefix("* "))
2253 .unwrap_or("");
2254 let rest = rest.trim_start();
2255 matches!(
2256 rest.get(..3),
2257 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2258 )
2259}
2260
2261fn is_decoration(line: &str) -> bool {
2263 line.starts_with('#')
2264 || line.starts_with("[!")
2265 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2266}
2267
2268fn is_banner(line: &str) -> bool {
2275 let plain = drop_spans(line, "**", "**");
2276 let plain = if plain.contains("](") {
2277 drop_spans(&plain, "[", ")")
2278 } else {
2279 plain
2280 };
2281 !plain.chars().any(char::is_alphanumeric)
2282}
2283
2284fn drop_spans(s: &str, open: &str, close: &str) -> String {
2288 let mut out = String::with_capacity(s.len());
2289 let mut rest = s;
2290 while let Some(at) = rest.find(open) {
2291 out.push_str(&rest[..at]);
2292 let after = &rest[at + open.len()..];
2293 match after.find(close) {
2294 Some(end) => rest = &after[end + close.len()..],
2295 None => return out,
2296 }
2297 }
2298 out.push_str(rest);
2299 out
2300}
2301
2302fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2320 static LOCKS: std::sync::LazyLock<
2321 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2322 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2323 LOCKS
2324 .lock()
2325 .unwrap_or_else(std::sync::PoisonError::into_inner)
2326 .entry(repo.to_path_buf())
2327 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2328 .clone()
2329}
2330
2331fn repo_label(repo: &Path, pr_url: &str) -> String {
2334 let parts: Vec<&str> = pr_url.split('/').collect();
2335 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2336 && at >= 2
2337 && !parts[at - 1].is_empty()
2338 && !parts[at - 2].is_empty()
2339 {
2340 return format!("{}/{}", parts[at - 2], parts[at - 1]);
2341 }
2342 repo.file_name()
2343 .map(|n| n.to_string_lossy().into_owned())
2344 .unwrap_or_default()
2345}
2346
2347fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2351 (pr.checks == Checks::Red).then(|| {
2352 format!(
2353 "Merged {repo_name} PR #{} with red checks: {} ({})",
2354 pr.number,
2355 if pr.failing.is_empty() {
2356 "(none named)".to_owned()
2357 } else {
2358 pr.failing.join(", ")
2359 },
2360 pr.url
2361 )
2362 })
2363}
2364
2365async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2369 let repo_name = repo_label(&state.repo, &pr.url);
2370 let Some(summary) = red_merge_summary(&repo_name, pr) else {
2371 return;
2372 };
2373 if let Some(rec) = state.pr.as_mut() {
2374 rec.red_at_merge = pr.failing.clone();
2375 }
2376 state.event("land", summary.clone());
2377 crate::notices::raise(crate::notices::merged_red(&state.id, &summary));
2378 if let Err(e) = ask::notify_text(&state.config.notify, &state.id, &summary).await {
2379 tracing::warn!("could not notify about a merge with red checks: {e:#}");
2380 }
2381}
2382
2383pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2390 let repo = state.repo.clone();
2391 let budget = state.config.graph.land_rounds;
2392 let mut round = 0usize;
2393 let mut rebases = 0usize;
2396 let mut waited = Duration::ZERO;
2397 let mut shown: BTreeSet<String> = BTreeSet::new();
2402
2403 state.status = RunStatus::Landing;
2411 state.event("land", format!("watching {pr_url}"));
2412 state.save()?;
2413
2414 loop {
2415 let seen = observe(&repo, pr_url).await?;
2416 let mut pr = seen.pr;
2417 pr.review_comments.retain(|c| !shown.contains(&c.body));
2418 state.pr = Some(crate::run::PrRecord {
2419 url: pr.url.clone(),
2420 number: pr.number,
2421 state: pr.state.as_str().to_owned(),
2422 checks: pr.checks.as_str().to_owned(),
2423 round,
2424 rounds: budget,
2425 red_at_merge: Vec::new(),
2426 });
2427 state.save()?;
2428
2429 match decide(&pr, round, budget, waited) {
2430 Step::Wait => {
2431 if waited >= WAIT_CEILING {
2432 let why = format!(
2433 "checks were still running after {} minutes",
2434 WAIT_CEILING.as_secs() / 60
2435 );
2436 stop(state, &repo, &pr, &why).await?;
2437 return Ok(pr);
2438 }
2439 waited += POLL;
2440 tokio::time::sleep(POLL).await;
2441 }
2442 Step::Done { merged } => {
2443 state.status = if merged {
2444 RunStatus::Merged
2445 } else {
2446 RunStatus::Ready
2447 };
2448 let detail = if merged {
2449 format!("{} was merged", pr.url)
2450 } else {
2451 format!("{} was closed without merging", pr.url)
2452 };
2453 state.merge = Some(MergeOutcome {
2454 mode: MergeMode::Pr,
2455 ok: merged,
2456 detail: detail.clone(),
2457 empty: false,
2458 });
2459 state.event("land", detail);
2460 state.save()?;
2461 write_pr_state_through(state, pr.state);
2462 return Ok(pr);
2463 }
2464 Step::Merge => {
2465 let subject = merge_subject(
2466 crate::graph::landing_title(state, &seen.title),
2467 &crate::graph::landing_subject_source(state),
2468 );
2469 let contested = contested_to_ask(state);
2476 if state.config.graph.land_approval || contested.is_some() {
2477 match approval_gate(state, &pr, &subject, contested.as_ref()).await? {
2478 ApprovalGate::Approved => {}
2479 ApprovalGate::Held => {
2480 stop(
2481 state,
2482 &repo,
2483 &pr,
2484 "the owner did not approve the merge (held or unanswered)",
2485 )
2486 .await?;
2487 return Ok(pr);
2488 }
2489 ApprovalGate::Pending => {
2497 state.parked = true;
2498 state.event(
2499 "land",
2500 "parked awaiting merge approval - resumes once answered",
2501 );
2502 state.save()?;
2503 return Ok(pr);
2504 }
2505 }
2506 }
2507 let argv = merge_argv(pr.number, &subject);
2508 let out = {
2509 let merge_lock = repo_merge_lock(&repo);
2510 let _merge_slot = merge_lock.lock().await;
2511 gh(&repo, &argv).await?
2512 };
2513 if out.0 {
2514 pr.state = PrLifecycle::Merged;
2515 state.status = RunStatus::Merged;
2516 state.merge = Some(MergeOutcome {
2517 mode: MergeMode::Pr,
2518 ok: true,
2519 detail: format!("gh {}", argv.join(" ")),
2520 empty: false,
2521 });
2522 if let Some(pr_record) = state.pr.as_mut() {
2527 pr_record.state = pr.state.as_str().to_owned();
2528 }
2529 state.event("land", format!("merged {} as `{subject}`", pr.url));
2530 announce_red_merge(state, &pr).await;
2531 state.save()?;
2532 write_pr_state_through(state, pr.state);
2533 return Ok(pr);
2534 }
2535 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
2536 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
2537 pr.state = PrLifecycle::Merged;
2538 state.status = RunStatus::Merged;
2539 state.merge = Some(outcome);
2540 if let Some(pr_record) = state.pr.as_mut() {
2541 pr_record.state = pr.state.as_str().to_owned();
2542 }
2543 state.event("land", format!("merged {} as `{subject}`", pr.url));
2544 announce_red_merge(state, &pr).await;
2545 state.save()?;
2546 write_pr_state_through(state, pr.state);
2547 return Ok(pr);
2548 }
2549 stop(
2550 state,
2551 &repo,
2552 &pr,
2553 &format!("`gh pr merge` failed: {}", out.1),
2554 )
2555 .await?;
2556 return Ok(pr);
2557 }
2558 Step::Rebase => {
2559 if rebases >= budget {
2565 let why = format!(
2566 "the base moved under this branch {budget} time(s) and it still does \
2567 not merge; rebasing again would only race it"
2568 );
2569 stop(state, &repo, &pr, &why).await?;
2570 return Ok(pr);
2571 }
2572 rebases += 1;
2573 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
2574 stop(
2575 state,
2576 &repo,
2577 &pr,
2578 "the pull request conflicts and this run has no winning branch to rebase",
2579 )
2580 .await?;
2581 return Ok(pr);
2582 };
2583 let base = state.base_branch.clone();
2584 state.event(
2585 "land",
2586 format!("{} no longer merges; rebasing onto {base}", pr.url),
2587 );
2588 state.save()?;
2589
2590 git::fetch(&repo, "origin", &base).await.ok();
2594 let scratch = state.dir().join("rebase");
2595 let onto = format!("origin/{base}");
2596 let rebased =
2597 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
2598 Ok(crate::rebase::Rebased::Applied) => Ok(None),
2599 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
2600 Err(e) => Err(e),
2601 };
2602 match rebased {
2603 Ok(None) => {
2604 let pushed = {
2605 let merge_lock = repo_merge_lock(&repo);
2606 let _merge_slot = merge_lock.lock().await;
2607 git::push_rewritten(&repo, "origin", &branch).await?
2608 };
2609 if !pushed.ok() {
2610 let why = format!(
2611 "rebased {branch} but could not push it: {}",
2612 pushed.stderr.trim()
2613 );
2614 stop(state, &repo, &pr, &why).await?;
2615 return Ok(pr);
2616 }
2617 state.event("land", format!("rebased {branch} onto {base}"));
2618 state.save()?;
2619 waited = Duration::ZERO;
2622 tokio::time::sleep(POLL).await;
2623 }
2624 Ok(Some(conflict)) => {
2627 let why = format!(
2628 "{} conflicts with {base} and the rebase did not apply: {}",
2629 pr.url,
2630 conflict.chars().take(600).collect::<String>()
2631 );
2632 stop(state, &repo, &pr, &why).await?;
2633 return Ok(pr);
2634 }
2635 Err(e) => {
2636 let why = format!("could not rebase {branch} onto {base}: {e:#}");
2637 stop(state, &repo, &pr, &why).await?;
2638 return Ok(pr);
2639 }
2640 }
2641 }
2642 Step::GiveUp { reason } => {
2643 stop(state, &repo, &pr, &reason).await?;
2644 return Ok(pr);
2645 }
2646 Step::Fix { reason } => {
2647 round += 1;
2648 waited = Duration::ZERO;
2649 for c in &pr.review_comments {
2650 shown.insert(c.body.clone());
2651 }
2652 state.event("land", format!("round {round}: {reason}"));
2653 state.save()?;
2654
2655 let logs = failing_logs(&repo, &seen.failing_urls).await;
2656 let was_red = pr.checks == Checks::Red;
2657 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
2658 Fixed::Committed => {}
2659 Fixed::Declined if was_red => {
2660 let why = format!(
2661 "the fixer produced no commit while {} check(s) were failing \
2662 ({}); stopping instead of looping on an unchanged tree",
2663 pr.failing.len(),
2664 pr.failing.join(", ")
2665 );
2666 stop(state, &repo, &pr, &why).await?;
2667 return Ok(pr);
2668 }
2669 Fixed::Declined => state.event(
2674 "land",
2675 format!("round {round}: fixer declined the comments, nothing committed"),
2676 ),
2677 Fixed::Failed(why) => {
2678 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
2679 return Ok(pr);
2680 }
2681 }
2682 state.save()?;
2683 }
2684 }
2685 }
2686}
2687
2688struct Seen {
2692 pr: PrState,
2693 title: String,
2694 failing_urls: Vec<(String, String)>,
2695}
2696
2697async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
2700 let view = gh(
2701 repo,
2702 &[
2703 "pr".to_owned(),
2704 "view".to_owned(),
2705 pr_url.to_owned(),
2706 "--json".to_owned(),
2707 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
2708 ],
2709 )
2710 .await?;
2711 if !view.0 {
2712 bail!("gh pr view {pr_url}: {}", view.1);
2713 }
2714 let mut pr = parse_pr(&view.1)?;
2715 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
2716
2717 let inline = gh(
2718 repo,
2719 &[
2720 "api".to_owned(),
2721 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
2722 ],
2723 )
2724 .await?;
2725 if inline.0 {
2726 match parse_inline_comments(&inline.1) {
2727 Ok(mut comments) => pr.review_comments.append(&mut comments),
2728 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
2731 }
2732 } else {
2733 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
2734 }
2735
2736 let failing_urls = raw
2737 .status_check_rollup
2738 .iter()
2739 .filter(|c| c.verdict() == Verdict::Fail)
2740 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
2741 .collect();
2742
2743 Ok(Seen {
2744 pr,
2745 title: raw.title,
2746 failing_urls,
2747 })
2748}
2749
2750#[doc(hidden)]
2752#[derive(Debug, PartialEq)]
2753pub enum Fixed {
2754 Committed,
2756 Declined,
2758 Failed(String),
2760}
2761
2762#[doc(hidden)]
2768pub async fn fix_round(
2769 state: &mut RunState,
2770 pr: &PrState,
2771 round: usize,
2772 budget: usize,
2773 reason: &str,
2774 logs: &str,
2775) -> Result<Fixed> {
2776 let winner = state
2777 .winner()
2778 .cloned()
2779 .context("landing needs a winning candidate; none is recorded on this run")?;
2780 let roles = state
2781 .config
2782 .resolve_roles()
2783 .context("resolve the roster for the fix round")?;
2784 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
2788 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
2789 _ => (
2790 state
2791 .config
2792 .agent(&winner.agent)
2793 .cloned()
2794 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
2795 format!("impl-{}", winner.label),
2796 ),
2797 };
2798
2799 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
2800 let mut seat = seat_of(state, &seat_key, &spec.id);
2801 let artifacts = agent::artifacts_dir(&state.dir());
2802 let prompt = if state.config.cache_dir().is_some() {
2803 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
2804 } else {
2805 prompt
2806 };
2807 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2811 let out = agent::invoke(
2812 &spec,
2813 &mut seat,
2814 &Invocation {
2815 cwd: &winner.worktree,
2816 prompt: &prompt,
2817 timeout: Duration::from_secs(state.config.graph.timeout_fix),
2818 allow_write: true,
2819 sessions: state.config.graph.sessions,
2820 artifacts: &artifacts,
2821 stem: &format!("land-{round}"),
2822 run: &state.id,
2823 node: "land",
2824 cache_dir: state.config.cache_dir().as_deref(),
2825 attachments: &[],
2826 writable: &[],
2827 },
2828 )
2829 .await;
2830 state.seats.insert(seat.key.clone(), seat);
2831
2832 match out {
2833 Ok(o) if o.quota_exhausted() => {
2834 return Ok(Fixed::Failed(
2835 "rate limited (quota); the fixer could not run".to_owned(),
2836 ));
2837 }
2838 Ok(o) if !o.usable() => {
2839 return Ok(Fixed::Failed(format!(
2840 "the fixer produced nothing usable (exit {:?}, timed out: {})",
2841 o.exit_code, o.timed_out
2842 )));
2843 }
2844 Ok(_) => {}
2845 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
2846 }
2847
2848 if let Ok(r) = git::rescue_commit(
2851 &winner.worktree,
2852 &format!("magi: land round {round} fixes (uncommitted work)"),
2853 )
2854 .await
2855 {
2856 state.note_withheld("land", &r.withheld);
2857 }
2858 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2859 if after == before {
2860 return Ok(Fixed::Declined);
2861 }
2862
2863 let remote = state.config.merge.remote.clone();
2864 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
2865 if !push.ok() {
2866 return Ok(Fixed::Failed(format!(
2867 "pushing {} to {remote} failed: {}",
2868 winner.branch, push.stderr
2869 )));
2870 }
2871 state.event(
2872 "land",
2873 format!("round {round}: pushed a fix to {}", winner.branch),
2874 );
2875 Ok(Fixed::Committed)
2876}
2877
2878pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
2880 if let Some(existing) = state.seats.get(key)
2881 && existing.agent == agent
2882 {
2883 return existing.clone();
2884 }
2885 let fresh = SeatState::new(key, agent, state.seed);
2886 state.seats.insert(key.to_owned(), fresh.clone());
2887 fresh
2888}
2889
2890fn fix_prompt(
2892 state: &RunState,
2893 pr: &PrState,
2894 round: usize,
2895 budget: usize,
2896 reason: &str,
2897 logs: &str,
2898) -> String {
2899 let mut s = format!(
2900 "Your patch is open as a pull request and it is not landing. Land round \
2901 {round} of {budget}.\n\n\
2902 Pull request: {}\n\n\
2903 What is holding it: {reason}\n\n\
2904 # The task\n\n{}\n",
2905 pr.url, state.instruction
2906 );
2907
2908 if pr.failing.is_empty() {
2909 s.push_str("\n# Failing checks\n\n(none)\n");
2910 } else {
2911 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
2912 if logs.trim().is_empty() {
2913 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
2914 } else {
2915 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
2916 }
2917 }
2918
2919 if pr.review_comments.is_empty() {
2920 s.push_str("\n# Review comments\n\n(none)\n");
2921 } else {
2922 s.push_str("\n# Review comments\n");
2923 for c in &pr.review_comments {
2924 let where_ = match (&c.path, c.line) {
2925 (Some(p), Some(l)) => format!(" ({p}:{l})"),
2926 (Some(p), None) => format!(" ({p})"),
2927 _ => String::new(),
2928 };
2929 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
2930 }
2931 }
2932
2933 s.push_str(
2934 "\n# Rules\n\n\
2935 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
2936 failing test; do not silence a lint with an allow attribute; do not \
2937 stretch a timeout to hide a race. If the check is right, the code is \
2938 wrong.\n\
2939 2. Change nothing the checks and the comments did not raise. A \
2940 drive-by refactor turns a one-line fix into a pull request that \
2941 needs reviewing again.\n\
2942 3. If a comment is wrong, say so with a checkable argument and change \
2943 nothing for it. A declined comment with a reason is a correct \
2944 outcome; a change made to appease a reviewer is not.\n\
2945 4. Commit in this worktree. magi pushes to the pull request's branch \
2946 for you; do not push, merge, or close anything yourself.\n\
2947 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
2948 # Output\n\n\
2949 Say what you changed and why, and what you declined and why.",
2950 );
2951
2952 let language = &state.config.graph.language;
2953 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
2954 let _ = write!(s, "\n\nWrite all prose in {language}.");
2955 }
2956 s.push_str(&crate::prompt::github_english(language));
2958 if let Some(overlay) = state.config.prompts.overlay("fix") {
2959 let _ = write!(s, "\n\n{overlay}");
2960 }
2961 s
2962}
2963
2964async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
2967 let mut out = String::new();
2968 for (name, url) in failing.iter().take(MAX_LOGS) {
2969 let args = match (job_of(url), run_of(url)) {
2970 (Some(job), _) => vec![
2971 "run".to_owned(),
2972 "view".to_owned(),
2973 "--log-failed".to_owned(),
2974 "--job".to_owned(),
2975 job,
2976 ],
2977 (None, Some(run)) => vec![
2978 "run".to_owned(),
2979 "view".to_owned(),
2980 run,
2981 "--log-failed".to_owned(),
2982 ],
2983 (None, None) => continue,
2985 };
2986 let (ok, body) = match gh(repo, &args).await {
2987 Ok(v) => v,
2988 Err(e) => (false, format!("{e:#}")),
2989 };
2990 if !ok && body.trim().is_empty() {
2991 continue;
2992 }
2993 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
2994 }
2995 out
2996}
2997
2998fn job_of(details_url: &str) -> Option<String> {
3001 let after = details_url.split("/job/").nth(1)?;
3002 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3003 (!id.is_empty()).then_some(id)
3004}
3005
3006fn run_of(details_url: &str) -> Option<String> {
3008 let after = details_url.split("/actions/runs/").nth(1)?;
3009 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
3010 (!id.is_empty()).then_some(id)
3011}
3012
3013fn stop_comment(run_id: &str, why: &str) -> String {
3017 format!(
3018 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
3019 The branch is untouched and the run is `{run_id}`. Nothing was merged."
3020 )
3021}
3022
3023async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
3028 let body = stop_comment(&state.id, why);
3029 let posted = gh(
3030 repo,
3031 &[
3032 "pr".to_owned(),
3033 "comment".to_owned(),
3034 pr.number.to_string(),
3035 "--body".to_owned(),
3036 body,
3037 ],
3038 )
3039 .await;
3040 match posted {
3041 Ok((true, _)) => {}
3042 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
3043 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
3044 }
3045 state.status = RunStatus::Blocked;
3046 state.merge = Some(MergeOutcome {
3047 mode: MergeMode::Pr,
3048 ok: false,
3049 detail: why.to_owned(),
3050 empty: false,
3051 });
3052 state.event("land", format!("stopped: {why}"));
3053 state.save()?;
3054 Ok(())
3055}
3056
3057async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
3073 let out = tokio::process::Command::new("gh")
3074 .args(args)
3075 .current_dir(cwd)
3076 .env_remove("GH_REPO")
3077 .quiet()
3078 .stdin(std::process::Stdio::null())
3079 .output()
3080 .await
3081 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
3082 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
3083 let err = String::from_utf8_lossy(&out.stderr);
3084 if body.trim().is_empty() {
3085 body = err.into_owned();
3086 } else if !err.trim().is_empty() {
3087 body.push_str(&err);
3088 }
3089 Ok((out.status.success(), body.trim().to_owned()))
3090}
3091
3092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3094enum Verdict {
3095 Pass,
3096 Fail,
3097 Pending,
3098 Unknown,
3099}
3100
3101#[derive(Debug, Deserialize)]
3102#[serde(rename_all = "camelCase")]
3103struct GhPr {
3104 #[serde(default)]
3105 url: String,
3106 #[serde(default)]
3107 number: u64,
3108 #[serde(default)]
3109 state: String,
3110 #[serde(default)]
3111 title: String,
3112 #[serde(default)]
3113 status_check_rollup: Vec<GhCheck>,
3114 #[serde(default)]
3121 merge_state_status: String,
3122 #[serde(default)]
3123 reviews: Vec<GhReview>,
3124 #[serde(default)]
3125 comments: Vec<GhComment>,
3126}
3127
3128#[derive(Debug, Deserialize)]
3133#[serde(rename_all = "camelCase")]
3134struct GhCheck {
3135 #[serde(default)]
3136 name: Option<String>,
3137 #[serde(default)]
3138 context: Option<String>,
3139 #[serde(default)]
3140 status: Option<String>,
3141 #[serde(default)]
3142 conclusion: Option<String>,
3143 #[serde(default)]
3144 state: Option<String>,
3145 #[serde(default)]
3146 details_url: Option<String>,
3147 #[serde(default)]
3148 target_url: Option<String>,
3149}
3150
3151impl GhCheck {
3152 fn label(&self) -> String {
3154 self.name
3155 .clone()
3156 .or_else(|| self.context.clone())
3157 .unwrap_or_else(|| "(unnamed check)".to_owned())
3158 }
3159
3160 fn url(&self) -> Option<&str> {
3162 self.details_url
3163 .as_deref()
3164 .or(self.target_url.as_deref())
3165 .filter(|u| !u.is_empty())
3166 }
3167
3168 fn verdict(&self) -> Verdict {
3176 if let Some(status) = self.status.as_deref() {
3177 if !status.eq_ignore_ascii_case("COMPLETED") {
3178 return Verdict::Pending;
3179 }
3180 }
3181 let outcome = self
3182 .conclusion
3183 .as_deref()
3184 .or(self.state.as_deref())
3185 .unwrap_or("");
3186 match outcome.to_ascii_uppercase().as_str() {
3187 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
3188 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
3189 | "ACTION_REQUIRED" => Verdict::Fail,
3190 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
3191 Verdict::Pending
3192 }
3193 _ => Verdict::Unknown,
3194 }
3195 }
3196}
3197
3198#[derive(Debug, Deserialize)]
3199struct GhAuthor {
3200 #[serde(default)]
3201 login: String,
3202}
3203
3204#[derive(Debug, Deserialize)]
3205struct GhReview {
3206 #[serde(default)]
3207 author: GhAuthor,
3208 #[serde(default)]
3209 body: String,
3210}
3211
3212#[derive(Debug, Deserialize)]
3213struct GhComment {
3214 #[serde(default)]
3215 author: GhAuthor,
3216 #[serde(default)]
3217 body: String,
3218}
3219
3220#[derive(Debug, Deserialize)]
3221struct GhUser {
3222 #[serde(default)]
3223 login: String,
3224}
3225
3226#[derive(Debug, Deserialize)]
3227struct GhInline {
3228 #[serde(default)]
3229 user: GhUser,
3230 #[serde(default)]
3231 path: Option<String>,
3232 #[serde(default)]
3233 line: Option<u64>,
3234 #[serde(default)]
3235 body: String,
3236}
3237
3238impl Default for GhAuthor {
3239 fn default() -> Self {
3240 Self {
3241 login: "(unknown)".to_owned(),
3242 }
3243 }
3244}
3245
3246impl Default for GhUser {
3247 fn default() -> Self {
3248 Self {
3249 login: "(unknown)".to_owned(),
3250 }
3251 }
3252}
3253
3254#[cfg(test)]
3255mod tests {
3256 use super::*;
3257 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
3258
3259 fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
3260 format!(
3261 r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
3262 )
3263 }
3264
3265 #[test]
3266 fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
3267 let ok = head_json("magi/27b2/A", "main", "OPEN", false);
3268 assert_eq!(
3269 closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
3270 Ok(())
3271 );
3272 for (json, why) in [
3273 (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
3274 (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
3275 (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
3276 (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
3277 (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
3278 (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
3279 ] {
3280 let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
3281 .unwrap_err()
3282 .why;
3283 assert!(err.contains(why), "{json}: {err}");
3284 }
3285 let moved = head_json("magi/27b2/A", "main", "OPEN", false);
3287 let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
3288 assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
3289 assert!(
3290 !closable(
3291 &head_json("x", "main", "OPEN", false),
3292 "magi/27b2/A",
3293 "main",
3294 &[]
3295 )
3296 .unwrap_err()
3297 .retry
3298 );
3299 assert!(is_forge_url("https://github.com/o/r.git"));
3300 assert!(is_forge_url("git@github.com:o/r.git"));
3301 assert!(!is_forge_url("/tmp/origin.git"));
3302 assert!(!is_forge_url("C:\\work\\origin.git"));
3303 assert!(!is_forge_url("file:///tmp/origin.git"));
3304 assert!(forge_unavailable(
3305 "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
3306 ));
3307 assert!(!forge_unavailable(
3308 "gh pr list failed: error connecting to api.github.com"
3309 ));
3310 assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
3311 assert!(
3313 closable(
3314 r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
3315 "b",
3316 "main",
3317 &["aaa".to_owned()]
3318 )
3319 .is_err()
3320 );
3321 }
3322
3323 #[test]
3324 fn the_close_comment_names_the_commit_on_the_base() {
3325 let e = crate::already::Evidence {
3326 proof: crate::already::Proof::PatchId,
3327 tip: "1234567890".to_owned(),
3328 commits: vec!["0e368de0000".to_owned()],
3329 };
3330 let c = superseded_comment("main", &e);
3331 assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
3332 }
3333
3334 const GREEN_OPEN: &str = r####"{
3336 "url": "https://github.com/yukimemi/magi/pull/10",
3337 "number": 10,
3338 "state": "OPEN",
3339 "mergeStateStatus": "CLEAN",
3340 "statusCheckRollup": [
3341 {
3342 "__typename": "CheckRun",
3343 "conclusion": "SKIPPED",
3344 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
3345 "name": "review",
3346 "status": "COMPLETED",
3347 "workflowName": "claude-review"
3348 },
3349 {
3350 "__typename": "CheckRun",
3351 "conclusion": "SUCCESS",
3352 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
3353 "name": "check (ubuntu-latest)",
3354 "status": "COMPLETED",
3355 "workflowName": "CI"
3356 },
3357 {
3358 "__typename": "CheckRun",
3359 "conclusion": "SUCCESS",
3360 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
3361 "name": "rustfmt",
3362 "status": "COMPLETED",
3363 "workflowName": "CI"
3364 },
3365 {
3366 "__typename": "StatusContext",
3367 "context": "CodeRabbit",
3368 "state": "SUCCESS",
3369 "targetUrl": ""
3370 }
3371 ],
3372 "reviews": [],
3373 "comments": [
3374 {
3375 "author": {
3376 "login": "coderabbitai"
3377 },
3378 "authorAssociation": "NONE",
3379 "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"
3380 }
3381 ]
3382}"####;
3383
3384 const RED_OPEN: &str = r####"{
3386 "url": "https://github.com/yukimemi/magi/pull/9",
3387 "number": 9,
3388 "state": "OPEN",
3389 "mergeStateStatus": "UNSTABLE",
3390 "statusCheckRollup": [
3391 {
3392 "__typename": "CheckRun",
3393 "conclusion": "SUCCESS",
3394 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
3395 "name": "check (ubuntu-latest)",
3396 "status": "COMPLETED",
3397 "workflowName": "CI"
3398 },
3399 {
3400 "__typename": "CheckRun",
3401 "conclusion": "SUCCESS",
3402 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
3403 "name": "rustfmt",
3404 "status": "COMPLETED",
3405 "workflowName": "CI"
3406 },
3407 {
3408 "__typename": "CheckRun",
3409 "conclusion": "FAILURE",
3410 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
3411 "name": "editorconfig",
3412 "status": "COMPLETED",
3413 "workflowName": "CI"
3414 },
3415 {
3416 "__typename": "StatusContext",
3417 "context": "CodeRabbit",
3418 "state": "SUCCESS",
3419 "targetUrl": ""
3420 }
3421 ],
3422 "reviews": [],
3423 "comments": [
3424 {
3425 "author": {
3426 "login": "coderabbitai"
3427 },
3428 "authorAssociation": "NONE",
3429 "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"
3430 }
3431 ]
3432}"####;
3433
3434 const PENDING_OPEN: &str = r####"{
3436 "url": "https://github.com/yukimemi/magi/pull/9",
3437 "number": 9,
3438 "state": "OPEN",
3439 "statusCheckRollup": [
3440 {
3441 "__typename": "CheckRun",
3442 "conclusion": "SUCCESS",
3443 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
3444 "name": "check (ubuntu-latest)",
3445 "status": "COMPLETED",
3446 "workflowName": "CI"
3447 },
3448 {
3449 "__typename": "CheckRun",
3450 "conclusion": "SUCCESS",
3451 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
3452 "name": "rustfmt",
3453 "status": "COMPLETED",
3454 "workflowName": "CI"
3455 },
3456 {
3457 "__typename": "CheckRun",
3458 "conclusion": null,
3459 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
3460 "name": "editorconfig",
3461 "status": "IN_PROGRESS",
3462 "workflowName": "CI"
3463 },
3464 {
3465 "__typename": "StatusContext",
3466 "context": "CodeRabbit",
3467 "state": "SUCCESS",
3468 "targetUrl": ""
3469 }
3470 ],
3471 "reviews": [],
3472 "comments": []
3473}"####;
3474
3475 const MERGED: &str = r####"{
3477 "url": "https://github.com/yukimemi/magi/pull/16",
3478 "number": 16,
3479 "state": "MERGED",
3480 "statusCheckRollup": [
3481 {
3482 "__typename": "CheckRun",
3483 "conclusion": "SUCCESS",
3484 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
3485 "name": "check (ubuntu-latest)",
3486 "status": "COMPLETED",
3487 "workflowName": "CI"
3488 },
3489 {
3490 "__typename": "CheckRun",
3491 "conclusion": "SUCCESS",
3492 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
3493 "name": "review",
3494 "status": "COMPLETED",
3495 "workflowName": "claude-review"
3496 }
3497 ],
3498 "reviews": [],
3499 "comments": []
3500}"####;
3501
3502 const REVIEWED_OPEN: &str = r####"{
3504 "url": "https://github.com/yukimemi/magi/pull/12",
3505 "number": 12,
3506 "state": "OPEN",
3507 "statusCheckRollup": [
3508 {
3509 "__typename": "CheckRun",
3510 "conclusion": "SUCCESS",
3511 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
3512 "name": "check (ubuntu-latest)",
3513 "status": "COMPLETED",
3514 "workflowName": "CI"
3515 },
3516 {
3517 "__typename": "CheckRun",
3518 "conclusion": "SUCCESS",
3519 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
3520 "name": "review",
3521 "status": "COMPLETED",
3522 "workflowName": "claude-review"
3523 }
3524 ],
3525 "reviews": [
3526 {
3527 "author": {
3528 "login": "claude"
3529 },
3530 "state": "COMMENTED",
3531 "body": ""
3532 }
3533 ],
3534 "comments": [
3535 {
3536 "author": {
3537 "login": "coderabbitai"
3538 },
3539 "authorAssociation": "NONE",
3540 "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"
3541 },
3542 {
3543 "author": {
3544 "login": "claude"
3545 },
3546 "authorAssociation": "NONE",
3547 "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"
3548 }
3549 ]
3550}"####;
3551
3552 const INLINE: &str = r####"[
3554 {
3555 "user": {
3556 "login": "claude[bot]"
3557 },
3558 "path": "src/graph.rs",
3559 "line": 231,
3560 "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"
3561 }
3562]"####;
3563
3564 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
3566<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
3567
3568> [!IMPORTANT]
3569> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
3570>
3571> This repository does not receive automatic reviews because it has fewer than 10 stars.
3572>
3573> <details>
3574> <summary>⚙️ Run configuration</summary>
3575>
3576> **Configuration used**: defaults
3577>
3578> **Review profile**: CHILL
3579>
3580> **Plan**: Team
3581>
3582> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
3583>
3584> </details>
3585
3586<!-- end of auto-generated comment: skip review by coderabbit.ai -->
3587
3588<!-- tips_start -->
3589
3590---
3591
3592Thanks 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.
3593
3594<details>
3595<summary>❤️ Share</summary>
3596
3597- [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"####;
3598
3599 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
3601
3602---
3603### Reviewing PR #16
3604
3605- [x] Read AGENTS.md conventions
3606- [x] Review `src/daemon.rs` changes
3607- [x] Review `src/main.rs` changes (new `doctor` reporting)
3608- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
3609- [x] Check test coverage for new behavior
3610- [x] Run verification commands (blocked — see note)
3611- [x] Post findings"####;
3612
3613 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
3615
3616---
3617### Review: `magi review <branch>` — cheap-half-only graph
3618
3619Read 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.
3620
3621**Correctness**
3622
3623- 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"####;
3624
3625 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
3626 PrState {
3627 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
3628 number: 16,
3629 state: PrLifecycle::Open,
3630 checks,
3631 blocking: if matches!(checks, Checks::Red) {
3635 Blocking::Yes
3636 } else {
3637 Blocking::No
3638 },
3639 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
3640 review_comments: (0..comments)
3641 .map(|i| ReviewComment {
3642 author: "coderabbitai".to_owned(),
3643 path: Some("src/graph.rs".to_owned()),
3644 line: Some(231),
3645 body: format!("finding {i}"),
3646 })
3647 .collect(),
3648 }
3649 }
3650
3651 #[test]
3652 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
3653 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
3654 assert_eq!(state.number, 10);
3655 assert_eq!(state.state, PrLifecycle::Open);
3656 assert_eq!(state.checks, Checks::Green);
3657 assert!(state.failing.is_empty());
3658 assert!(
3659 state.review_comments.is_empty(),
3660 "the only comment is CodeRabbit's trigger notice: {:?}",
3661 state.review_comments
3662 );
3663 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
3664 }
3665
3666 #[test]
3667 fn a_failing_check_parses_as_red_and_is_named() {
3668 let state = parse_pr(RED_OPEN).expect("red fixture parses");
3669 assert_eq!(state.checks, Checks::Red);
3670 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
3671 let mut blocking = state.clone();
3678 blocking.blocking = Blocking::Yes;
3679 match decide(&blocking, 0, 4, Duration::ZERO) {
3680 Step::Fix { reason } => {
3681 assert!(reason.contains("editorconfig"), "reason: {reason}");
3682 assert!(reason.contains("failing"), "reason: {reason}");
3683 }
3684 other => panic!("expected a fix round, got {other:?}"),
3685 }
3686 }
3687
3688 #[test]
3689 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
3690 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
3691 assert_eq!(state.checks, Checks::Pending);
3692 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
3693 }
3694
3695 #[test]
3696 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
3697 let state = parse_pr(MERGED).expect("merged fixture parses");
3698 assert_eq!(state.state, PrLifecycle::Merged);
3699 assert_eq!(
3700 decide(&state, 0, 4, Duration::ZERO),
3701 Step::Done { merged: true }
3702 );
3703 }
3704
3705 #[test]
3706 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
3707 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
3708 assert_eq!(state.checks, Checks::Green);
3709 let authors: Vec<&str> = state
3710 .review_comments
3711 .iter()
3712 .map(|c| c.author.as_str())
3713 .collect();
3714 assert_eq!(
3715 authors,
3716 vec!["claude"],
3717 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
3718 );
3719 match decide(&state, 0, 4, Duration::ZERO) {
3720 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
3721 other => panic!("expected a fix round, got {other:?}"),
3722 }
3723 }
3724
3725 #[test]
3726 fn inline_review_comments_keep_their_file_and_line() {
3727 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
3728 assert_eq!(comments.len(), 1);
3729 assert_eq!(comments[0].author, "claude[bot]");
3730 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
3731 assert_eq!(comments[0].line, Some(231));
3732 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
3733 }
3734
3735 #[test]
3736 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
3737 assert!(
3738 is_noise(CODERABBIT_TRIGGER),
3739 "CodeRabbit's trigger notice declares itself not a review"
3740 );
3741 assert!(
3742 is_noise(CLAUDE_CHECKLIST),
3743 "a progress checklist asks for nothing"
3744 );
3745 assert!(
3746 !is_noise(CLAUDE_FINDING),
3747 "a review that names a bug is input, not noise"
3748 );
3749
3750 let mut clean = pr(Checks::Green, &[], 0);
3751 clean.review_comments.push(ReviewComment {
3752 author: "coderabbitai".to_owned(),
3753 path: None,
3754 line: None,
3755 body: CODERABBIT_TRIGGER.to_owned(),
3756 });
3757 clean.review_comments.retain(|c| !is_noise(&c.body));
3758 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
3759
3760 let mut found = pr(Checks::Green, &[], 0);
3761 found.review_comments.push(ReviewComment {
3762 author: "claude".to_owned(),
3763 path: None,
3764 line: None,
3765 body: CLAUDE_FINDING.to_owned(),
3766 });
3767 found.review_comments.retain(|c| !is_noise(&c.body));
3768 assert!(matches!(
3769 decide(&found, 0, 4, Duration::ZERO),
3770 Step::Fix { .. }
3771 ));
3772 }
3773
3774 #[test]
3775 fn the_policy_table_holds_for_every_combination_that_matters() {
3776 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
3777 (
3778 "pending checks are waited for, even on the last round",
3779 pr(Checks::Pending, &[], 0),
3780 4,
3781 4,
3782 Duration::ZERO,
3783 Step::Wait,
3784 ),
3785 (
3786 "red checks are fixed",
3787 pr(Checks::Red, &["editorconfig"], 0),
3788 0,
3789 4,
3790 Duration::ZERO,
3791 Step::Fix {
3792 reason: "1 check(s) failing: editorconfig".to_owned(),
3793 },
3794 ),
3795 (
3796 "green with comments is fixed, not merged",
3797 pr(Checks::Green, &[], 2),
3798 1,
3799 4,
3800 Duration::ZERO,
3801 Step::Fix {
3802 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
3803 .to_owned(),
3804 },
3805 ),
3806 (
3807 "green and clean merges",
3808 pr(Checks::Green, &[], 0),
3809 3,
3810 4,
3811 Duration::ZERO,
3812 Step::Merge,
3813 ),
3814 (
3815 "an unreadable rollup is waited on while the grace lasts",
3816 pr(Checks::Unknown, &[], 0),
3817 0,
3818 4,
3819 Duration::ZERO,
3820 Step::Wait,
3821 ),
3822 (
3823 "an unreadable rollup is never merged once the grace is spent",
3824 pr(Checks::Unknown, &[], 0),
3825 0,
3826 4,
3827 CHECKS_GRACE,
3828 Step::GiveUp {
3829 reason: "no check status is readable on the pull request after 3 minute(s); \
3830 refusing to merge on a guess"
3831 .to_owned(),
3832 },
3833 ),
3834 ];
3835 for (what, state, round, budget, waited, want) in cases {
3836 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
3837 }
3838 }
3839
3840 #[test]
3841 fn the_forge_verdict_survives_the_round_trip_from_gh() {
3842 let green = parse_pr(GREEN_OPEN).expect("parse");
3846 assert_eq!(green.blocking, Blocking::No);
3847 let red = parse_pr(RED_OPEN).expect("parse");
3848 assert_eq!(
3849 red.blocking,
3850 Blocking::No,
3851 "`UNSTABLE` is mergeable: the red check is one nobody requires"
3852 );
3853 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
3854 let quiet =
3856 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
3857 assert_eq!(quiet.blocking, Blocking::Unsaid);
3858 }
3859
3860 #[test]
3861 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
3862 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
3868 nonblocking.blocking = Blocking::No;
3869 assert_eq!(
3870 decide(&nonblocking, 0, 4, Duration::ZERO),
3871 Step::Merge,
3872 "the forge says nothing is in the way, so nothing is"
3873 );
3874
3875 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
3877 blocking.blocking = Blocking::Yes;
3878 assert!(matches!(
3879 decide(&blocking, 0, 4, Duration::ZERO),
3880 Step::Fix { .. }
3881 ));
3882
3883 let mut commented = pr(Checks::Red, &["coverage"], 1);
3886 commented.blocking = Blocking::No;
3887 assert!(matches!(
3888 decide(&commented, 0, 4, Duration::ZERO),
3889 Step::Fix { .. }
3890 ));
3891
3892 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
3894 unsaid.blocking = Blocking::Unsaid;
3895 assert!(matches!(
3896 decide(&unsaid, 0, 4, Duration::ZERO),
3897 Step::Fix { .. }
3898 ));
3899 }
3900
3901 #[test]
3902 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
3903 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
3904 red.blocking = Blocking::No;
3905 assert_eq!(
3906 decide(&red, 0, 4, Duration::ZERO),
3907 Step::Merge,
3908 "announcing must not change the decision"
3909 );
3910 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
3911 assert!(said.contains("yukimemi/magi"), "{said}");
3912 assert!(said.contains("#16"), "{said}");
3913 assert!(
3914 said.contains("https://github.com/yukimemi/magi/pull/16"),
3915 "{said}"
3916 );
3917 assert!(
3918 said.contains("test (windows-latest)") && said.contains("coverage"),
3919 "{said}"
3920 );
3921
3922 let green = pr(Checks::Green, &["stale"], 0);
3924 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
3925 }
3926
3927 #[test]
3928 fn the_repo_label_comes_from_the_pull_request_url() {
3929 let p = Path::new("/tmp/checkout");
3930 assert_eq!(
3931 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
3932 "yukimemi/magi"
3933 );
3934 assert_eq!(repo_label(p, "not a url"), "checkout");
3935 }
3936
3937 #[test]
3938 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
3939 let mut conflicted = pr(Checks::Green, &[], 0);
3944 conflicted.blocking = Blocking::Conflict;
3945 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
3946
3947 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
3951 red.blocking = Blocking::Conflict;
3952 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
3953
3954 let mut merged = pr(Checks::Red, &[], 0);
3956 merged.blocking = Blocking::Conflict;
3957 merged.state = PrLifecycle::Merged;
3958 assert_eq!(
3959 decide(&merged, 0, 4, Duration::ZERO),
3960 Step::Done { merged: true }
3961 );
3962 }
3963
3964 #[test]
3965 fn the_forge_verdict_is_read_off_merge_state_status() {
3966 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
3969 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
3970 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
3971 }
3972 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
3973 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
3974 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
3975 for quiet in ["", "UNKNOWN"] {
3978 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
3979 assert!(Blocking::of(quiet).stops_a_merge());
3980 }
3981 }
3982
3983 #[test]
3984 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
3985 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
3986 let jj = "could not determine current branch: failed to run git: not on any branch";
3988
3989 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
3990 .expect("the forge says merged, so it merged");
3991 assert!(landed.ok);
3992 assert!(
3993 landed.detail.contains("but the pull request is merged"),
3994 "the record must not read as a clean success: {}",
3995 landed.detail
3996 );
3997 assert!(
3998 landed.detail.contains("not on any branch"),
3999 "and it must keep what the command actually said: {}",
4000 landed.detail
4001 );
4002
4003 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
4005 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
4006 assert!(merged_after_all(&argv, jj, None).is_none());
4008 }
4009
4010 #[test]
4011 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
4012 let mut state = pr(Checks::Red, &["editorconfig"], 3);
4013 state.state = PrLifecycle::Closed;
4014 assert_eq!(
4015 decide(&state, 0, 4, Duration::ZERO),
4016 Step::Done { merged: false },
4017 "a human closing the pull request ends the loop, whatever CI says"
4018 );
4019 }
4020
4021 #[test]
4022 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
4023 let red = decide(
4024 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
4025 4,
4026 4,
4027 Duration::ZERO,
4028 );
4029 match red {
4030 Step::GiveUp { reason } => {
4031 assert!(reason.contains("editorconfig"), "reason: {reason}");
4032 assert!(reason.contains("test (macos)"), "reason: {reason}");
4033 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
4034 }
4035 other => panic!("expected a give-up, got {other:?}"),
4036 }
4037
4038 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
4039 match commented {
4040 Step::GiveUp { reason } => {
4041 assert!(reason.contains("unresolved"), "reason: {reason}");
4042 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
4043 }
4044 other => panic!("expected a give-up, got {other:?}"),
4045 }
4046 }
4047
4048 #[test]
4049 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
4050 let candidate_commit = "magi: candidate A (uncommitted work)";
4051 let subject = merge_subject(candidate_commit, "add retries to the uploader");
4052 let argv = merge_argv(16, &subject);
4053
4054 assert!(argv.contains(&"--squash".to_owned()));
4055 assert!(argv.contains(&"--delete-branch".to_owned()));
4056 assert!(argv.contains(&"--subject".to_owned()));
4057 assert_eq!(
4058 argv.last().map(String::as_str),
4059 Some("add retries to the uploader"),
4060 "the subject must not be the candidate commit message"
4061 );
4062 assert_ne!(subject, candidate_commit);
4063 }
4064
4065 #[test]
4066 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
4067 assert_eq!(
4068 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
4069 "feat: a queue, an unattended loop, and a phone UI"
4070 );
4071 assert_eq!(
4072 merge_subject("", "# port the retry logic\n\ndetails"),
4073 "port the retry logic",
4074 "an empty title falls back to the task's first line, heading marks stripped"
4075 );
4076 }
4077
4078 #[test]
4079 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
4080 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
4081 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
4082 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
4083 assert_eq!(job_of("https://coderabbit.ai/status"), None);
4084 assert_eq!(run_of(""), None);
4085 }
4086
4087 #[test]
4088 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
4089 let mut out = Vec::new();
4090 push_if_outstanding(
4091 &mut out,
4092 ReviewComment {
4093 author: "yukimemi".to_owned(),
4094 path: None,
4095 line: None,
4096 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
4097 },
4098 );
4099 assert!(out.is_empty());
4100 }
4101
4102 fn run_state() -> RunState {
4106 RunState::new(
4107 std::path::PathBuf::from("/repo/magi"),
4108 "main".to_owned(),
4109 "abcdef1234".to_owned(),
4110 "add retries to the uploader".to_owned(),
4111 crate::config::Config::default(),
4112 )
4113 }
4114
4115 fn green_pr() -> PrState {
4116 PrState {
4117 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
4118 number: 42,
4119 state: PrLifecycle::Open,
4120 checks: Checks::Green,
4121 blocking: Blocking::No,
4123 failing: Vec::new(),
4124 review_comments: vec![ReviewComment {
4125 author: "coderabbitai".to_owned(),
4126 path: Some("src/land.rs".to_owned()),
4127 line: Some(212),
4128 body: "this branch never checks the exit code".to_owned(),
4129 }],
4130 }
4131 }
4132
4133 #[test]
4134 fn github_facing_land_text_is_english_whatever_the_language() {
4135 let mut state = run_state();
4136 state.config.graph.language = "ja".to_owned();
4137 let comment = stop_comment(&state.id, "checks are still red");
4138 assert!(comment.is_ascii(), "{comment}");
4139 assert!(comment.starts_with(MARKER));
4140
4141 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
4142 let ja_at = p.find("Write all prose in ja").unwrap();
4143 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
4144 assert!(ja_at < rule_at, "{p}");
4145 assert!(p.contains("stays in Japanese"), "{p}");
4146
4147 state.config.graph.language = "en".to_owned();
4148 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
4149 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
4150 assert!(!p.contains("does not apply"), "{p}");
4151 }
4152
4153 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
4154
4155 fn panel() -> String {
4156 approval_panel(
4157 &run_state(),
4158 &green_pr(),
4159 NUMSTAT,
4160 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
4161 &[
4162 "land: ask before merging".to_owned(),
4163 "land: colour the diff".to_owned(),
4164 ],
4165 "feat: merge approval from the phone",
4166 )
4167 }
4168
4169 #[test]
4170 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
4171 let html = panel();
4172 for needle in [
4173 "42",
4174 "main",
4175 "src/land.rs",
4176 "src/web.rs",
4177 "assets/logo.png",
4178 "feat: merge approval from the phone",
4179 "land: ask before merging",
4180 "land: colour the diff",
4181 "coderabbitai",
4182 "this branch never checks the exit code",
4183 "green",
4184 ] {
4185 assert!(html.contains(needle), "the panel must state `{needle}`");
4186 }
4187 }
4188
4189 fn winning_candidate(summary: &str) -> Candidate {
4192 Candidate {
4193 index: 0,
4194 label: 'A',
4195 agent: "opus".to_owned(),
4196 branch: "magi/x/A".to_owned(),
4197 worktree: PathBuf::from("/wt/A"),
4198 summary: summary.to_owned(),
4199 stat: String::new(),
4200 files: 1,
4201 commits: 1,
4202 empty: false,
4203 failed: None,
4204 verified_noop: None,
4205 duration_ms: 0,
4206 folded: false,
4207 }
4208 }
4209
4210 fn uncontested_tally() -> Tally {
4211 Tally {
4212 first_choice: BTreeMap::from([('A', 1)]),
4213 borda: BTreeMap::new(),
4214 winner: 'A',
4215 rankings: 1,
4216 unanimous_initial: true,
4217 deliberated: false,
4218 changed_votes: 0,
4219 unanimous_final: true,
4220 tie_break: None,
4221 judges: 1,
4222 present: 1,
4223 quorum: 1,
4224 met_quorum: true,
4225 uncontested: None,
4226 }
4227 }
4228
4229 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
4230 ReviewRecord {
4231 attempts: 0,
4232 reviewer,
4233 agent: agent.to_owned(),
4234 summary: summary.to_owned(),
4235 findings: Vec::new(),
4236 vote: None,
4237 failed: None,
4238 duration_ms: 0,
4239 }
4240 }
4241
4242 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
4243 let answered = reviews.len();
4244 ReviewRound {
4245 round,
4246 head: "abc1234".to_owned(),
4247 verified_head: None,
4248 verified_at: None,
4249 reviews,
4250 e2e: Vec::new(),
4251 verify_retried: false,
4252 e2e_deferred: false,
4253 e2e_defer_reason: None,
4254 fix: None,
4255 blocking: 0,
4256 answered,
4257 expected: answered,
4258 clean: true,
4259 progressed: false,
4260 vote_split: false,
4261 reconsideration: Vec::new(),
4262 verdict: None,
4263 }
4264 }
4265
4266 #[test]
4267 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
4268 let en = panel();
4269 assert!(en.contains("Task"), "{en}");
4270 assert!(en.contains("add retries to the uploader"), "{en}");
4271
4272 let mut state = run_state();
4273 state.config.graph.language = "ja".to_owned();
4274 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4275 assert!(ja.contains("タスク"), "{ja}");
4276 assert!(
4277 ja.contains("add retries to the uploader"),
4278 "the task itself is not translated: {ja}"
4279 );
4280 }
4281
4282 #[test]
4283 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
4284 let html = panel();
4288 assert!(!html.contains("What changed"), "{html}");
4289 assert!(!html.contains("Review verdict"), "{html}");
4290 }
4291
4292 #[test]
4293 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
4294 let mut state = run_state();
4295 state.candidates = vec![winning_candidate("")];
4296 state.tally = Some(uncontested_tally());
4297 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4298 assert!(
4299 !html.contains("What changed"),
4300 "an empty summary must not render an empty box: {html}"
4301 );
4302 }
4303
4304 #[test]
4305 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
4306 let mut state = run_state();
4307 state.candidates = vec![winning_candidate(
4308 "Added a retry loop around the uploader PUT call.",
4309 )];
4310 state.tally = Some(uncontested_tally());
4311 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4312 assert!(en.contains("What changed"), "{en}");
4313 assert!(
4314 en.contains("Added a retry loop around the uploader PUT call."),
4315 "{en}"
4316 );
4317
4318 state.config.graph.language = "ja".to_owned();
4319 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4320 assert!(ja.contains("変更内容"), "{ja}");
4321 assert!(
4322 ja.contains("Added a retry loop around the uploader PUT call."),
4323 "{ja}"
4324 );
4325 }
4326
4327 #[test]
4328 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
4329 let mut state = run_state();
4330 state.reviews = vec![
4331 review_round(
4332 1,
4333 vec![review_record(1, "alpha", "found a race, sent back")],
4334 ),
4335 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
4336 ];
4337 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4338 assert!(en.contains("Review verdict"), "{en}");
4339 assert!(en.contains("race is fixed, clean"), "{en}");
4340 assert!(
4341 !en.contains("found a race, sent back"),
4342 "only the round that actually cleared the merge should show: {en}"
4343 );
4344
4345 state.config.graph.language = "ja".to_owned();
4346 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4347 assert!(ja.contains("レビューの結論"), "{ja}");
4348 assert!(ja.contains("レビュアー"), "{ja}");
4349 assert!(ja.contains("race is fixed, clean"), "{ja}");
4350 }
4351
4352 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
4358 ReviewRecord {
4359 attempts: 0,
4360 reviewer,
4361 agent: agent.to_owned(),
4362 summary: String::new(),
4363 findings: Vec::new(),
4364 vote: None,
4365 failed: Some(reason.to_owned()),
4366 duration_ms: 0,
4367 }
4368 }
4369
4370 #[test]
4371 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
4372 let mut state = run_state();
4373 state.reviews = vec![review_round(
4374 1,
4375 vec![
4376 review_record(1, "alpha", "clean, nothing to add"),
4377 unanswered_review_record(2, "beta", "timed out"),
4378 ],
4379 )];
4380 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4381 assert!(en.contains("clean, nothing to add"), "{en}");
4382 assert!(
4383 en.contains("produced no answer: timed out"),
4384 "a seat that never answered must say so, not render a blank box: {en}"
4385 );
4386 assert!(
4387 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
4388 "no reviewer box may be left empty: {en}"
4389 );
4390
4391 state.config.graph.language = "ja".to_owned();
4392 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
4393 assert!(ja.contains("回答なし: timed out"), "{ja}");
4394 }
4395
4396 #[test]
4397 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
4398 let html = panel();
4399 assert!(!html.contains("<script"), "no script survives the csp");
4400 assert!(!html.contains("<form"), "form-action is 'none'");
4401 let pr = green_pr();
4402 assert_eq!(
4403 html.matches("http").count(),
4404 html.matches(pr.url.as_str()).count(),
4405 "the only http url in the panel is the pull request's own link"
4406 );
4407 }
4408
4409 #[test]
4410 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
4411 let html = panel();
4412 assert!(
4413 html.contains(">+</span>"),
4414 "an added line carries a `+` in the gutter, not only a background"
4415 );
4416 assert!(
4417 html.contains(">-</span>"),
4418 "a removed line carries a `-` in the gutter, not only a background"
4419 );
4420 assert!(
4421 html.contains(">new line</span>"),
4422 "the marker is moved to the gutter, so the body is printed once without it"
4423 );
4424 }
4425
4426 #[test]
4427 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
4428 let total = DIFF_MAX_LINES + 100;
4429 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
4430 let html = approval_panel(
4431 &run_state(),
4432 &green_pr(),
4433 NUMSTAT,
4434 &diff,
4435 &[],
4436 "feat: something long",
4437 );
4438 assert!(
4439 html.contains(&format!("100 of {total} diff lines omitted")),
4440 "the note must say exactly how much was cut"
4441 );
4442 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
4443 assert!(
4444 !html.contains(&format!("line {DIFF_MAX_LINES}")),
4445 "nothing past the threshold is rendered"
4446 );
4447 assert!(
4448 html.contains("/repo/magi"),
4449 "the note says where the rest is"
4450 );
4451 }
4452
4453 #[test]
4454 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
4455 let html = approval_panel(
4456 &run_state(),
4457 &green_pr(),
4458 "1\t2\tsrc/<b>&\"x\"'.rs",
4459 "",
4460 &[],
4461 "subject",
4462 );
4463 assert!(html.contains("src/<b>&"x"'.rs"));
4464 assert!(
4465 !html.contains("<b>"),
4466 "an agent-influenced path must never become markup"
4467 );
4468 }
4469
4470 #[tokio::test]
4471 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
4472 let a = std::path::PathBuf::from("/repo/a");
4473 let b = std::path::PathBuf::from("/repo/b");
4474
4475 let held = repo_merge_lock(&a).lock_owned().await;
4476
4477 assert!(
4480 repo_merge_lock(&a).try_lock().is_err(),
4481 "a second merge into the same repository must not proceed concurrently"
4482 );
4483
4484 assert!(
4488 repo_merge_lock(&b).try_lock().is_ok(),
4489 "a different repository's merge lock must be independent"
4490 );
4491
4492 drop(held);
4493 assert!(
4494 repo_merge_lock(&a).try_lock().is_ok(),
4495 "the lock is released once the holder is done"
4496 );
4497 }
4498
4499 #[test]
4500 fn only_the_merge_choice_merges_and_silence_holds() {
4501 let table = [
4502 (None, Approval::Hold),
4503 (Some("merge"), Approval::Merge),
4504 (Some(" merge\n"), Approval::Merge),
4505 (Some("hold"), Approval::Hold),
4506 (Some(""), Approval::Hold),
4507 (Some("yes"), Approval::Hold),
4508 ];
4509 for (answer, want) in table {
4510 assert_eq!(
4511 approval(answer),
4512 want,
4513 "answer {answer:?} must resolve to {want:?}"
4514 );
4515 }
4516 }
4517
4518 #[tokio::test]
4519 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
4520 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4521 let mut state = run_state();
4522 state.config.graph.land_approval = true;
4523 let pr = green_pr();
4524
4525 let gate = approval_gate(&mut state, &pr, "feat: x", None)
4526 .await
4527 .unwrap();
4528 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
4529 assert!(
4530 !state.parked,
4531 "approval_gate itself never sets `parked`; only its caller does"
4532 );
4533
4534 let store = ask::Questions::open();
4535 let filed: Vec<_> = store
4536 .list()
4537 .into_iter()
4538 .filter(|q| q.run == state.id)
4539 .collect();
4540 assert_eq!(filed.len(), 1, "exactly one question is filed");
4541 assert_eq!(filed[0].node, APPROVAL_NODE);
4542 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
4543 assert!(filed[0].status.open());
4544
4545 let again = approval_gate(&mut state, &pr, "feat: x", None)
4549 .await
4550 .unwrap();
4551 assert_eq!(again, ApprovalGate::Pending);
4552 let still_one = store
4553 .list()
4554 .into_iter()
4555 .filter(|q| q.run == state.id)
4556 .count();
4557 assert_eq!(
4558 still_one, 1,
4559 "asking twice must not double-file the question"
4560 );
4561 }
4562
4563 #[tokio::test]
4564 async fn approving_the_existing_question_is_read_back_as_approved() {
4565 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4566 let mut state = run_state();
4567 state.config.graph.land_approval = true;
4568 let pr = green_pr();
4569 assert_eq!(
4570 approval_gate(&mut state, &pr, "feat: x", None)
4571 .await
4572 .unwrap(),
4573 ApprovalGate::Pending
4574 );
4575
4576 let store = ask::Questions::open();
4577 let mut q = store
4578 .list()
4579 .into_iter()
4580 .find(|q| q.run == state.id)
4581 .expect("filed above");
4582 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
4583 store.put(&mut q).unwrap();
4584
4585 assert_eq!(
4586 approval_gate(&mut state, &pr, "feat: x", None)
4587 .await
4588 .unwrap(),
4589 ApprovalGate::Approved
4590 );
4591 }
4592
4593 #[tokio::test]
4594 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
4595 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4596 let store = ask::Questions::open();
4597
4598 let mut held_state = run_state();
4599 held_state.config.graph.land_approval = true;
4600 let pr = green_pr();
4601 approval_gate(&mut held_state, &pr, "feat: x", None)
4602 .await
4603 .unwrap();
4604 let mut q = store
4605 .list()
4606 .into_iter()
4607 .find(|q| q.run == held_state.id)
4608 .expect("filed above");
4609 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
4610 store.put(&mut q).unwrap();
4611 assert_eq!(
4612 approval_gate(&mut held_state, &pr, "feat: x", None)
4613 .await
4614 .unwrap(),
4615 ApprovalGate::Held
4616 );
4617
4618 let mut abandoned_state = run_state();
4619 abandoned_state.config.graph.land_approval = true;
4620 approval_gate(&mut abandoned_state, &pr, "feat: x", None)
4621 .await
4622 .unwrap();
4623 let mut q = store
4624 .list()
4625 .into_iter()
4626 .find(|q| q.run == abandoned_state.id)
4627 .expect("filed above");
4628 q.abandon("no answer within the timeout");
4629 store.put(&mut q).unwrap();
4630 assert_eq!(
4631 approval_gate(&mut abandoned_state, &pr, "feat: x", None)
4632 .await
4633 .unwrap(),
4634 ApprovalGate::Held,
4635 "silence must never merge"
4636 );
4637 }
4638
4639 fn contested() -> ContestedHandoff {
4640 let finding = |id: &str, n: u32| crate::verdict::Finding {
4641 id: id.to_owned(),
4642 severity: crate::verdict::Severity::Major,
4643 file: Some("src/a.rs".to_owned()),
4644 line: Some(n),
4645 title: format!("problem {id}"),
4646 detail: String::new(),
4647 };
4648 ContestedHandoff {
4649 findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
4650 rejecters: vec![(1, "alpha".to_owned())],
4651 }
4652 }
4653
4654 #[test]
4655 fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
4656 let mut state = run_state();
4657 assert!(contested_to_ask(&state).is_none(), "nothing recorded");
4658 state.contested_handoff = Some(contested());
4659 assert!(contested_to_ask(&state).is_some());
4660 state.config.graph.hold_contested_merge = false;
4661 assert!(
4662 contested_to_ask(&state).is_none(),
4663 "the switch restores today"
4664 );
4665 }
4666
4667 #[test]
4668 fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
4669 for lang in ["en", "ja"] {
4670 let mut cfg = crate::config::Config::default();
4671 cfg.graph.language = lang.to_owned();
4672 let w = words(&cfg.graph.language);
4673 let text = w.approval_detail(
4674 "https://github.com/yukimemi/magi/pull/42",
4675 "main",
4676 "feat: x",
4677 Some(&contested()),
4678 );
4679 assert!(text.contains("pull/42"), "{text}");
4680 assert!(
4681 text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
4682 "{text}"
4683 );
4684 assert!(text.contains("R3-1-5"), "{text}");
4685 assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
4686 assert!(text.contains("2"), "the rest are counted: {text}");
4687 assert!(text.contains("#1 (alpha)"), "{text}");
4688 }
4689 let plain = words("en").approval_detail("u", "main", "s", None);
4690 assert!(!plain.contains("reject"), "{plain}");
4691 }
4692
4693 #[tokio::test]
4694 async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
4695 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4696 let mut state = run_state();
4697 state.config.graph.land_approval = false;
4698 state.contested_handoff = Some(contested());
4699 let pr = green_pr();
4700 let c = contested_to_ask(&state);
4701 assert_eq!(
4702 approval_gate(&mut state, &pr, "feat: x", c.as_ref())
4703 .await
4704 .unwrap(),
4705 ApprovalGate::Pending,
4706 "silence is a hold"
4707 );
4708 let store = ask::Questions::open();
4709 let filed: Vec<_> = store
4710 .list()
4711 .into_iter()
4712 .filter(|q| q.run == state.id)
4713 .collect();
4714 assert_eq!(filed.len(), 1);
4715 assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
4716
4717 assert_eq!(
4718 approval_gate(&mut state, &pr, "feat: x", c.as_ref())
4719 .await
4720 .unwrap(),
4721 ApprovalGate::Pending
4722 );
4723 let mut q = store
4724 .list()
4725 .into_iter()
4726 .find(|q| q.run == state.id)
4727 .unwrap();
4728 assert_eq!(q.id, filed[0].id, "the same question after a resume");
4729 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
4730 store.put(&mut q).unwrap();
4731 assert_eq!(
4732 approval_gate(&mut state, &pr, "feat: x", c.as_ref())
4733 .await
4734 .unwrap(),
4735 ApprovalGate::Approved
4736 );
4737 }
4738
4739 #[test]
4740 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
4741 let rows = parse_numstat(NUMSTAT);
4742 assert_eq!(
4743 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
4744 ["src/web.rs", "src/land.rs", "assets/logo.png"]
4745 );
4746 assert_eq!(rows[2].added, None, "a binary file has no line counts");
4747 }
4748 #[test]
4749 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
4750 let mut state = run_state();
4754 state.config.graph.language = "ja".to_owned();
4755 let pr = green_pr();
4756 let commits = ["c1".to_owned()];
4757
4758 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4759 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
4760 assert!(ja.contains("squash されるコミット"), "{ja}");
4761 assert!(ja.contains("レビューコメント"), "{ja}");
4762 assert!(ja.contains("差分"), "{ja}");
4763 assert!(
4764 !ja.contains("Commits being squashed"),
4765 "no English left over"
4766 );
4767
4768 let w = words("ja");
4769 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
4770 assert!(
4771 w.approval_detail("http://x/1", "main", "feat: x", None)
4772 .contains("パネル")
4773 );
4774
4775 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
4777 assert!(ja.contains("feat: x"), "nor is the merge subject");
4778
4779 state.config.graph.language = "en".to_owned();
4782 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4783 assert!(en.contains("Commits being squashed"), "{en}");
4784 assert_eq!(words("Klingon").html_lang, "en");
4785 }
4786
4787 #[test]
4791 fn pick_open_pr_classifies_by_count_and_base() {
4792 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
4793 assert_eq!(
4794 pick_open_pr(one, "main").unwrap(),
4795 OpenPr::One {
4796 url: "https://x/pull/58".into(),
4797 title: "t".into()
4798 }
4799 );
4800 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
4801 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
4802 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
4803 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
4804 assert_eq!(
4805 pick_open_pr(two, "main").unwrap(),
4806 OpenPr::Many(vec!["u1".into(), "u2".into()])
4807 );
4808 assert!(pick_open_pr("not json", "main").is_err());
4809 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
4811 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
4812 }
4813
4814 #[test]
4815 fn pick_merged_pr_picks_the_unique_match() {
4816 let json = r#"[
4817 {"url": "https://github.com/o/r/pull/42", "number": 42,
4818 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
4819 ]"#;
4820 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4821 let found = pick_merged_pr(json, "main", created_at)
4822 .expect("valid json")
4823 .expect("one unambiguous match");
4824 assert_eq!(found.url, "https://github.com/o/r/pull/42");
4825 assert_eq!(found.number, 42);
4826 }
4827
4828 #[test]
4832 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
4833 let json = r#"[
4834 {"url": "https://github.com/o/r/pull/42", "number": 42,
4835 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
4836 {"url": "https://github.com/o/r/pull/43", "number": 43,
4837 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
4838 ]"#;
4839 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4840 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4841 }
4842
4843 #[test]
4847 fn pick_merged_pr_ignores_a_different_base_branch() {
4848 let json = r#"[
4849 {"url": "https://github.com/o/r/pull/42", "number": 42,
4850 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
4851 ]"#;
4852 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4853 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4854 }
4855
4856 #[test]
4859 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
4860 let json = r#"[
4861 {"url": "https://github.com/o/r/pull/42", "number": 42,
4862 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
4863 ]"#;
4864 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4865 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4866 }
4867
4868 #[test]
4869 fn slug_of_pr_url_reads_host_owner_and_repo() {
4870 assert_eq!(
4871 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
4872 Some("github.com/yukimemi/shun")
4873 );
4874 }
4875
4876 #[test]
4877 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
4878 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
4879 assert_eq!(slug_of_pr_url("not a url at all"), None);
4880 assert_eq!(slug_of_pr_url("https://github.com"), None);
4881 }
4882
4883 #[test]
4884 fn slug_of_repo_url_reads_host_owner_and_repo() {
4885 assert_eq!(
4886 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
4887 Some("github.com/yukimemi/magi")
4888 );
4889 assert_eq!(slug_of_repo_url("https://github.com"), None);
4890 }
4891
4892 #[test]
4893 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
4894 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
4895 .expect("same repo, different case");
4896 }
4897
4898 #[test]
4903 fn ensure_same_repo_refuses_a_different_repo() {
4904 let err =
4905 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
4906 let msg = format!("{err:#}");
4907 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4908 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
4909 }
4910
4911 #[test]
4916 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
4917 let err = ensure_same_repo(
4918 "github.com/yukimemi/magi",
4919 "github.example.com/yukimemi/magi",
4920 )
4921 .unwrap_err();
4922 let msg = format!("{err:#}");
4923 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4924 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
4925 }
4926
4927 #[tokio::test]
4932 async fn find_external_merge_returns_none_without_a_winner() {
4933 let state = RunState::new(
4934 PathBuf::from("/no/such/repo"),
4935 "main".to_owned(),
4936 "0000000000000000000000000000000000000000".to_owned(),
4937 "irrelevant".to_owned(),
4938 crate::config::Config::default(),
4939 );
4940 assert_eq!(find_external_merge(&state).await.unwrap(), None);
4941 }
4942
4943 fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
4944 let mut run = RunState::new(
4945 PathBuf::from(repo),
4946 "main".to_owned(),
4947 "abcdef1234".to_owned(),
4948 "x".to_owned(),
4949 crate::config::Config::default(),
4950 );
4951 run.id = id.to_owned();
4952 run.status = status;
4953 run.pr = Some(crate::run::PrRecord {
4954 number: url.rsplit('/').next().unwrap().parse().unwrap(),
4955 url: url.to_owned(),
4956 state: state.to_owned(),
4957 checks: "red".to_owned(),
4958 round: 0,
4959 rounds: 2,
4960 red_at_merge: Vec::new(),
4961 });
4962 run.save_under(home).unwrap();
4963 }
4964
4965 fn recorded(home: &Path, id: &str) -> String {
4966 let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
4967 serde_json::from_str::<RunState>(&body)
4968 .unwrap()
4969 .pr
4970 .unwrap()
4971 .state
4972 }
4973
4974 const PR: &str = "https://github.com/o/r/pull/7";
4975
4976 #[test]
4977 fn write_through_updates_predecessors_and_siblings_only() {
4978 let tmp = tempfile::tempdir().unwrap();
4979 let h = tmp.path();
4980 pr_run(
4981 h,
4982 "20261004-100000-aaaa",
4983 "/repo/r",
4984 RunStatus::Superseded,
4985 PR,
4986 "open",
4987 );
4988 pr_run(
4989 h,
4990 "20261004-100100-bbbb",
4991 "/repo/r",
4992 RunStatus::Blocked,
4993 PR,
4994 "open",
4995 );
4996 pr_run(
4998 h,
4999 "20261004-100200-cccc",
5000 "/repo/r",
5001 RunStatus::Landing,
5002 PR,
5003 "open",
5004 );
5005 pr_run(
5007 h,
5008 "20261004-100300-dddd",
5009 "/repo/other",
5010 RunStatus::Blocked,
5011 "https://github.com/o/other/pull/7",
5012 "open",
5013 );
5014 pr_run(
5016 h,
5017 "20261004-100400-eeee",
5018 "/repo/r",
5019 RunStatus::Blocked,
5020 "https://github.com/o/r/pull/8",
5021 "open",
5022 );
5023 pr_run(
5024 h,
5025 "20261004-100500-ffff",
5026 "/repo/r",
5027 RunStatus::Merged,
5028 PR,
5029 "open",
5030 );
5031 let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
5032
5033 assert_eq!(
5034 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
5035 2
5036 );
5037 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
5038 assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
5039 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
5040 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
5041 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
5042 assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
5044 assert_eq!(
5046 write_pr_state_through_in(h, &source, PrLifecycle::Merged),
5047 0
5048 );
5049 let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
5050 assert!(hit.events.iter().any(|e| e.message.contains("merged")));
5051 }
5052
5053 #[test]
5054 fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
5055 let tmp = tempfile::tempdir().unwrap();
5056 let h = tmp.path();
5057 let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
5058 pr_run(
5059 h,
5060 "20261004-100000-aaaa",
5061 "/repo/r",
5062 RunStatus::Superseded,
5063 &url(1),
5064 "open",
5065 );
5066 pr_run(
5067 h,
5068 "20261004-100100-bbbb",
5069 "/repo/r",
5070 RunStatus::Blocked,
5071 &url(2),
5072 "open",
5073 );
5074 pr_run(
5075 h,
5076 "20261004-100200-cccc",
5077 "/repo/r",
5078 RunStatus::Ready,
5079 &url(3),
5080 "open",
5081 );
5082 pr_run(
5083 h,
5084 "20261004-100300-dddd",
5085 "/repo/r",
5086 RunStatus::Ready,
5087 &url(4),
5088 "open",
5089 );
5090 pr_run(
5091 h,
5092 "20261004-100400-eeee",
5093 "/repo/r",
5094 RunStatus::Implementing,
5095 &url(1),
5096 "open",
5097 );
5098 assert_eq!(stale_open_prs(h).len(), 4);
5099
5100 let mut known = BTreeMap::new();
5101 known.insert(url(1), PrLifecycle::Merged);
5102 known.insert(url(2), PrLifecycle::Closed);
5103 known.insert(url(3), PrLifecycle::Open);
5104 assert_eq!(apply_pr_states(h, &known), 2);
5106 assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
5107 assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
5108 assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
5109 assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
5110 assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
5111 assert_eq!(apply_pr_states(h, &known), 0);
5112 }
5113}