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::{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 })
312}
313
314pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
334 match pr.state {
335 PrLifecycle::Merged => return Step::Done { merged: true },
336 PrLifecycle::Closed => return Step::Done { merged: false },
337 PrLifecycle::Open => {}
338 }
339
340 if pr.blocking == Blocking::Conflict {
343 return Step::Rebase;
344 }
345
346 let spent = round >= budget;
347 match pr.checks {
348 Checks::Pending => Step::Wait,
349 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
350 Checks::Unknown => Step::GiveUp {
351 reason: format!(
352 "no check status is readable on the pull request after {} minute(s); \
353 refusing to merge on a guess",
354 CHECKS_GRACE.as_secs() / 60
355 ),
356 },
357 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
364 Checks::Red => {
365 let what = format!(
366 "{} check(s) failing: {}",
367 pr.failing.len(),
368 pr.failing.join(", ")
369 );
370 if spent {
371 Step::GiveUp {
372 reason: format!("{what} — still red after {budget} fix round(s)"),
373 }
374 } else {
375 Step::Fix { reason: what }
376 }
377 }
378 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
379 Checks::Green => {
380 let what = format!(
381 "checks are green but {} review comment(s) are unresolved: {}",
382 pr.review_comments.len(),
383 authors(&pr.review_comments)
384 );
385 if spent {
386 Step::GiveUp {
387 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
388 }
389 } else {
390 Step::Fix { reason: what }
391 }
392 }
393 }
394}
395
396fn authors(comments: &[ReviewComment]) -> String {
398 let mut seen: Vec<&str> = Vec::new();
399 for c in comments {
400 if !seen.contains(&c.author.as_str()) {
401 seen.push(&c.author);
402 }
403 }
404 seen.join(", ")
405}
406
407pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
411 vec![
412 "pr".to_owned(),
413 "merge".to_owned(),
414 number.to_string(),
415 "--squash".to_owned(),
416 "--delete-branch".to_owned(),
417 "--subject".to_owned(),
418 subject.to_owned(),
419 ]
420}
421
422pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
429 let title = pr_title.trim();
430 if !title.is_empty() && !title.starts_with("magi: candidate") {
431 return title.to_owned();
432 }
433 let first = instruction
434 .lines()
435 .map(str::trim)
436 .find(|l| !l.is_empty())
437 .unwrap_or("magi: land the winning candidate");
438 first.trim_start_matches(['#', ' ']).to_owned()
439}
440
441pub const APPROVE: &str = "merge";
443
444pub const HOLD: &str = "hold";
446
447pub const APPROVAL_NODE: &str = "land-approval";
453
454pub const DIFF_MAX_LINES: usize = 400;
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum Approval {
466 Merge,
468 Hold,
470}
471
472pub fn approval(answer: Option<&str>) -> Approval {
480 match answer {
481 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
482 _ => Approval::Hold,
483 }
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum ApprovalGate {
489 Approved,
491 Held,
494 Pending,
496}
497
498fn esc(s: &str) -> String {
508 let mut out = String::with_capacity(s.len());
509 for c in s.chars() {
510 match c {
511 '&' => out.push_str("&"),
512 '<' => out.push_str("<"),
513 '>' => out.push_str(">"),
514 '"' => out.push_str("""),
515 '\'' => out.push_str("'"),
516 _ => out.push(c),
517 }
518 }
519 out
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
524struct StatRow {
525 path: String,
526 added: Option<u64>,
528 removed: Option<u64>,
529}
530
531impl StatRow {
532 fn churn(&self) -> u64 {
535 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
536 }
537}
538
539fn parse_numstat(numstat: &str) -> Vec<StatRow> {
545 let mut rows: Vec<StatRow> = numstat
546 .lines()
547 .filter_map(|line| {
548 let mut parts = line.splitn(3, '\t');
549 let added = parts.next()?.trim();
550 let removed = parts.next()?.trim();
551 let path = parts.next()?.trim();
552 if path.is_empty() {
553 return None;
554 }
555 Some(StatRow {
556 path: path.to_owned(),
557 added: added.parse().ok(),
558 removed: removed.parse().ok(),
559 })
560 })
561 .collect();
562 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
565 rows
566}
567
568fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
577 if line.starts_with("+++") || line.starts_with("---") {
578 (" ", "color:#57606a;font-weight:600", line)
579 } else if let Some(body) = line.strip_prefix('+') {
580 ("+", "background:#e6ffec;color:#0a3622", body)
581 } else if let Some(body) = line.strip_prefix('-') {
582 ("-", "background:#ffebe9;color:#5c1a17", body)
583 } else if line.starts_with("@@") {
584 ("~", "background:#eef2ff;color:#3730a3", line)
585 } else if let Some(body) = line.strip_prefix(' ') {
586 (" ", "", body)
587 } else {
588 (" ", "color:#57606a;font-weight:600", line)
589 }
590}
591
592struct Words {
601 html_lang: &'static str,
602 task: &'static str,
603 what_changed: &'static str,
604 review_verdict: &'static str,
605 reviewer: &'static str,
606 reviewer_no_answer: &'static str,
607 checks: &'static str,
608 nothing_failing: &'static str,
609 files_changed: &'static str,
610 commits: &'static str,
611 no_commits: &'static str,
612 comments: &'static str,
613 no_comments: &'static str,
614 diff: &'static str,
615 truncated: &'static str,
616 lands_as: &'static str,
617}
618
619const EN: Words = Words {
620 html_lang: "en",
621 task: "Task",
622 what_changed: "What changed",
623 review_verdict: "Review verdict",
624 reviewer: "Reviewer",
625 reviewer_no_answer: "produced no answer",
626 checks: "Checks",
627 nothing_failing: "Nothing failing.",
628 files_changed: "file(s) changed",
629 commits: "Commits being squashed",
630 no_commits: "No commit subjects could be read from the branch.",
631 comments: "Review comments",
632 no_comments: "Nothing outstanding at this observation.",
633 diff: "Diff",
634 truncated: "Truncated",
635 lands_as: "They land as one commit titled",
636};
637
638const JA: Words = Words {
639 html_lang: "ja",
640 task: "タスク",
641 what_changed: "変更内容",
642 review_verdict: "レビューの結論",
643 reviewer: "レビュアー",
644 reviewer_no_answer: "回答なし",
645 checks: "チェック",
646 nothing_failing: "失敗しているものはありません。",
647 files_changed: "ファイル変更",
648 commits: "squash されるコミット",
649 no_commits: "ブランチからコミット件名を読めませんでした。",
650 comments: "レビューコメント",
651 no_comments: "この時点で未対応のものはありません。",
652 diff: "差分",
653 truncated: "省略",
654 lands_as: "これらは次の件名の1コミットとして入ります:",
655};
656
657impl Words {
658 fn lands_as_tail(&self) -> &'static str {
662 if self.html_lang == "ja" {
663 "。この件名も承認の対象です。"
664 } else {
665 ", which you are approving too."
666 }
667 }
668
669 fn approval_summary(&self, number: u64, subject: &str) -> String {
671 if self.html_lang == "ja" {
672 format!("プルリクエスト #{number} をマージ: {subject}")
673 } else {
674 format!("merge pull request #{number}: {subject}")
675 }
676 }
677
678 fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
680 if self.html_lang == "ja" {
681 format!(
682 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
683 できる状態です。差分の要約・パッチ・squash されるコミットは\
684 下のパネルにあります。"
685 )
686 } else {
687 format!(
688 "{url} is green and ready to squash into `{base}` as `{subject}`. \
689 The panel holds the diffstat, the patch and the commits being squashed."
690 )
691 }
692 }
693
694 fn truncated_note(
696 &self,
697 omitted: usize,
698 total: usize,
699 shown: usize,
700 where_: &str,
701 base: &str,
702 head: &str,
703 ) -> String {
704 if self.html_lang == "ja" {
705 format!(
706 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
707 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
708 プルリクエストにあります。"
709 )
710 } else {
711 format!(
712 "{omitted} of {total} diff lines omitted after the first {shown}. \
713 The whole patch is in <code>{where_}</code> \
714 (<code>git diff {base}...{head}</code>) and on the pull request."
715 )
716 }
717 }
718}
719
720fn words(language: &str) -> &'static Words {
723 let l = language.trim();
724 if l.eq_ignore_ascii_case("ja")
725 || l.eq_ignore_ascii_case("jp")
726 || l.eq_ignore_ascii_case("japanese")
727 || l.eq_ignore_ascii_case("日本語")
728 {
729 &JA
730 } else {
731 &EN
732 }
733}
734
735pub fn approval_panel(
747 state: &RunState,
748 pr: &PrState,
749 diffstat: &str,
750 diff: &str,
751 commits: &[String],
752 subject: &str,
753) -> String {
754 let rows = parse_numstat(diffstat);
755 let w = words(&state.config.graph.language);
756 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
757
758 let _ = writeln!(
759 h,
760 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
761 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
762 w.html_lang
763 );
764 let _ = writeln!(
765 h,
766 "<title>merge #{} — {}</title>\n</head>",
767 pr.number,
768 esc(subject)
769 );
770 h.push_str(
771 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
772 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
773 word-break:break-word\">\n",
774 );
775
776 let _ = writeln!(
778 h,
779 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
780 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
781 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
782 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
783 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
784 pr.number,
785 esc(&state.base_branch),
786 esc(subject),
787 esc(&state.id),
788 esc(&pr.url),
789 esc(&pr.url),
790 );
791
792 let _ = writeln!(
795 h,
796 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
797 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
798 w.task,
799 esc(&state.instruction)
800 );
801
802 if let Some(summary) = state
804 .winner()
805 .map(|c| c.summary.as_str())
806 .filter(|s| !s.is_empty())
807 {
808 let _ = writeln!(
809 h,
810 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
811 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
812 w.what_changed,
813 esc(summary)
814 );
815 }
816
817 if let Some(round) = state.reviews.last() {
820 let _ = writeln!(
821 h,
822 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
823 w.review_verdict
824 );
825 for r in &round.reviews {
826 let body = match &r.failed {
833 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
834 None => esc(&r.summary),
835 };
836 let _ = writeln!(
837 h,
838 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
839 border-radius:6px\">\
840 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
841 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
842 w.reviewer,
843 r.reviewer,
844 esc(&r.agent),
845 body,
846 );
847 }
848 }
849
850 let _ = writeln!(
851 h,
852 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
853 w.checks,
854 esc(pr.checks.as_str())
855 );
856 if pr.failing.is_empty() {
857 let _ = writeln!(
858 h,
859 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
860 w.nothing_failing
861 );
862 } else {
863 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
864 for f in &pr.failing {
865 let _ = writeln!(h, "<li>{}</li>", esc(f));
866 }
867 h.push_str("</ul>\n");
868 }
869
870 let _ = writeln!(
873 h,
874 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
875 rows.len(),
876 w.files_changed
877 );
878 h.push_str(
879 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
880 <thead><tr>\
881 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
882 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
883 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
884 </th></tr></thead>\n<tbody>\n",
885 );
886 let mut total_added = 0u64;
887 let mut total_removed = 0u64;
888 for r in &rows {
889 total_added += r.added.unwrap_or(0);
890 total_removed += r.removed.unwrap_or(0);
891 let cell = |n: Option<u64>| match n {
892 Some(n) => n.to_string(),
893 None => "bin".to_owned(),
894 };
895 let _ = writeln!(
896 h,
897 "<tr>\
898 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
899 font-family:ui-monospace,monospace\">{}</td>\
900 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
901 color:#0a3622\">{}</td>\
902 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
903 color:#5c1a17\">{}</td></tr>",
904 esc(&r.path),
905 cell(r.added),
906 cell(r.removed),
907 );
908 }
909 let _ = writeln!(
910 h,
911 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
912 <td style=\"padding:4px 2px\">total</td>\
913 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
914 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
915 </tr></tfoot>\n</table>"
916 );
917
918 let _ = writeln!(
920 h,
921 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
922 w.commits
923 );
924 if commits.is_empty() {
925 h.push_str(&format!(
926 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
927 w.no_commits
928 ));
929 } else {
930 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
931 for c in commits {
932 let _ = writeln!(h, "<li>{}</li>", esc(c));
933 }
934 h.push_str("</ol>\n");
935 }
936 let _ = writeln!(
937 h,
938 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
939 w.lands_as,
940 esc(subject),
941 w.lands_as_tail()
942 );
943
944 let _ = writeln!(
946 h,
947 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
948 w.comments
949 );
950 if pr.review_comments.is_empty() {
951 h.push_str(&format!(
952 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
953 w.no_comments
954 ));
955 } else {
956 for c in &pr.review_comments {
957 let anchor = match (&c.path, c.line) {
958 (Some(p), Some(l)) => format!("{p}:{l}"),
959 (Some(p), None) => p.clone(),
960 _ => "pull request thread".to_owned(),
961 };
962 let _ = writeln!(
963 h,
964 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
965 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
966 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
967 esc(&c.author),
968 esc(&anchor),
969 esc(&tail(&c.body, 800)),
970 );
971 }
972 }
973
974 let total = diff.lines().count();
976 let shown = total.min(DIFF_MAX_LINES);
977 let _ = writeln!(
978 h,
979 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
980 w.diff
981 );
982 h.push_str(
983 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
984 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
985 );
986 for line in diff.lines().take(shown) {
987 let (gutter, style, body) = diff_row(line);
988 let _ = writeln!(
989 h,
990 "<div style=\"display:flex;{style}\">\
991 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
992 border-right:1px solid #d0d7de\">{gutter}</span>\
993 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
994 esc(body),
995 );
996 }
997 h.push_str("</div>\n");
998 if total > shown {
999 let omitted = total - shown;
1000 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1001 let where_ = state.winner().map_or_else(
1002 || state.repo.display().to_string(),
1003 |w| w.worktree.display().to_string(),
1004 );
1005 let _ = writeln!(
1006 h,
1007 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1008 font-size:13px\">{}: {}</p>",
1009 w.truncated,
1010 w.truncated_note(
1011 omitted,
1012 total,
1013 shown,
1014 &esc(&where_),
1015 &esc(&state.base_branch),
1016 &esc(head),
1017 ),
1018 );
1019 }
1020
1021 h.push_str("</body>\n</html>\n");
1022 h
1023}
1024
1025async fn approval_gate(state: &mut RunState, pr: &PrState, subject: &str) -> Result<ApprovalGate> {
1045 let store = ask::Questions::open();
1046 let existing = store
1047 .list()
1048 .into_iter()
1049 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
1050 .max_by(|a, b| a.id.cmp(&b.id));
1051
1052 let q = match existing {
1053 Some(q) => q,
1054 None => {
1055 let (worktree, head) = match state.winner() {
1056 Some(w) => (w.worktree.clone(), w.branch.clone()),
1057 None => (state.repo.clone(), "HEAD".to_owned()),
1058 };
1059 let base = state.base_branch.clone();
1060 let range = format!("{base}...{head}");
1061 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1065 .await
1066 .map(|o| o.stdout)
1067 .unwrap_or_default();
1068 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1069 let commits: Vec<String> = git::git_raw(
1070 &worktree,
1071 &[
1072 "log",
1073 "--reverse",
1074 "--format=%s",
1075 &format!("{base}..{head}"),
1076 ],
1077 )
1078 .await
1079 .map(|o| o.stdout)
1080 .unwrap_or_default()
1081 .lines()
1082 .filter(|l| !l.trim().is_empty())
1083 .map(str::to_owned)
1084 .collect();
1085
1086 let w = words(&state.config.graph.language);
1087 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1088 let mut fresh = ask::Question::new(
1089 state.id.clone(),
1090 APPROVAL_NODE.to_owned(),
1091 "land".to_owned(),
1092 w.approval_summary(pr.number, subject),
1093 w.approval_detail(&pr.url, &base, subject),
1094 vec![APPROVE.to_owned(), HOLD.to_owned()],
1095 );
1096 store
1097 .put_panel(&mut fresh, &html, &[])
1098 .context("write the merge approval panel")?;
1099 store
1100 .put(&mut fresh)
1101 .context("file the merge approval question")?;
1102 state.event(
1103 "land",
1104 format!("asking for merge approval ({})", fresh.short()),
1105 );
1106 state.save()?;
1107 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1108 tracing::warn!(
1112 "could not notify about merge approval question {}: {e:#} - \
1113 the web UI is the only surface for it now",
1114 fresh.short()
1115 );
1116 }
1117 fresh
1118 }
1119 };
1120
1121 Ok(match q.status {
1122 ask::QuestionStatus::Open => ApprovalGate::Pending,
1123 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1127 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1131 Approval::Merge => ApprovalGate::Approved,
1132 Approval::Hold => ApprovalGate::Held,
1133 },
1134 })
1135}
1136
1137pub fn parse_pr(json: &str) -> Result<PrState> {
1140 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1141 let state = match raw.state.to_ascii_uppercase().as_str() {
1142 "OPEN" => PrLifecycle::Open,
1143 "MERGED" => PrLifecycle::Merged,
1144 "CLOSED" => PrLifecycle::Closed,
1145 other => bail!("unknown pull request state `{other}`"),
1146 };
1147
1148 let mut failing = Vec::new();
1149 let mut pending = false;
1150 let mut unknown = false;
1151 for check in &raw.status_check_rollup {
1152 match check.verdict() {
1153 Verdict::Pass => {}
1154 Verdict::Pending => pending = true,
1155 Verdict::Fail => failing.push(check.label()),
1156 Verdict::Unknown => unknown = true,
1157 }
1158 }
1159 let checks = if raw.status_check_rollup.is_empty() {
1160 Checks::Unknown
1161 } else if pending {
1162 Checks::Pending
1163 } else if !failing.is_empty() {
1164 Checks::Red
1165 } else if unknown {
1166 Checks::Unknown
1167 } else {
1168 Checks::Green
1169 };
1170
1171 let mut review_comments = Vec::new();
1172 for r in raw.reviews {
1173 push_if_outstanding(
1174 &mut review_comments,
1175 ReviewComment {
1176 author: r.author.login,
1177 path: None,
1178 line: None,
1179 body: r.body,
1180 },
1181 );
1182 }
1183 for c in raw.comments {
1184 push_if_outstanding(
1185 &mut review_comments,
1186 ReviewComment {
1187 author: c.author.login,
1188 path: None,
1189 line: None,
1190 body: c.body,
1191 },
1192 );
1193 }
1194
1195 Ok(PrState {
1196 url: raw.url,
1197 number: raw.number,
1198 state,
1199 checks,
1200 failing,
1201 review_comments,
1202 blocking: Blocking::of(&raw.merge_state_status),
1203 })
1204}
1205
1206pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1216 let view = gh(
1217 repo,
1218 &[
1219 "pr".to_owned(),
1220 "view".to_owned(),
1221 pr_url.to_owned(),
1222 "--json".to_owned(),
1223 "state".to_owned(),
1224 ],
1225 )
1226 .await?;
1227 if !view.0 {
1228 bail!("gh pr view {pr_url}: {}", view.1);
1229 }
1230 Ok(parse_pr(&view.1)?.state)
1234}
1235
1236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1240pub struct ExternalMerge {
1241 pub url: String,
1243 pub number: u64,
1245}
1246
1247#[derive(Debug, Deserialize)]
1248#[serde(rename_all = "camelCase")]
1249struct GhMergedPr {
1250 url: String,
1251 number: u64,
1252 merged_at: String,
1253 base_ref_name: String,
1254}
1255
1256fn pick_merged_pr(
1270 json: &str,
1271 base_branch: &str,
1272 created_at: Timestamp,
1273) -> Result<Option<ExternalMerge>> {
1274 let raw: Vec<GhMergedPr> =
1275 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1276 let mut matches: Vec<ExternalMerge> = Vec::new();
1277 for pr in raw {
1278 if pr.base_ref_name != base_branch {
1279 continue;
1280 }
1281 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1282 continue;
1283 };
1284 if merged_at < created_at {
1285 continue;
1286 }
1287 matches.push(ExternalMerge {
1288 url: pr.url,
1289 number: pr.number,
1290 });
1291 }
1292 if matches.len() == 1 {
1293 Ok(matches.pop())
1294 } else {
1295 Ok(None)
1296 }
1297}
1298
1299pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1312 let Some(winner) = state.winner() else {
1313 return Ok(None);
1314 };
1315 let branch = winner.branch.clone();
1316 let out = gh(
1317 &state.repo,
1318 &[
1319 "pr".to_owned(),
1320 "list".to_owned(),
1321 "--head".to_owned(),
1322 branch.clone(),
1323 "--state".to_owned(),
1324 "merged".to_owned(),
1325 "--json".to_owned(),
1326 "url,number,mergedAt,baseRefName".to_owned(),
1327 ],
1328 )
1329 .await?;
1330 if !out.0 {
1331 bail!("gh pr list --head {branch}: {}", out.1);
1332 }
1333 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1334}
1335
1336pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1348 let out = tokio::process::Command::new("git")
1349 .args(["merge-base", "--is-ancestor", branch, base_branch])
1350 .current_dir(repo)
1351 .quiet()
1352 .stdin(std::process::Stdio::null())
1353 .output()
1354 .await
1355 .context("spawn git merge-base --is-ancestor")?;
1356 Ok(out.status.success())
1357}
1358
1359pub async fn correct_manual_merge(
1390 state: &mut RunState,
1391 url: &str,
1392) -> Result<(RunStatus, RunStatus)> {
1393 match lifecycle(&state.repo, url).await? {
1394 PrLifecycle::Merged => {}
1395 other => bail!(
1396 "{url} is {}, not merged; refusing to record {} as merged on a guess",
1397 other.as_str(),
1398 state.id
1399 ),
1400 }
1401 let before = state.status;
1402 if let Err(e) = land(state, url).await {
1403 state.status = RunStatus::Blocked;
1410 state.event("fold", format!("manual-merge correction failed: {e:#}"));
1411 state.save()?;
1412 return Err(e).context(format!("confirming the merge of {url}"));
1413 }
1414 state.event(
1415 "fold",
1416 "operator recorded this pull request as a manual merge; this run never \
1417 re-entered `land`, so `bump::after_merge` did not run for it - a release \
1418 bump this change might warrant has to be filed by hand",
1419 );
1420 state.save()?;
1421 Ok((before, state.status))
1422}
1423
1424pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1431 let raw: Vec<GhInline> =
1432 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1433 let mut out = Vec::new();
1434 for c in raw {
1435 push_if_outstanding(
1436 &mut out,
1437 ReviewComment {
1438 author: c.user.login,
1439 path: c.path,
1440 line: c.line,
1441 body: c.body,
1442 },
1443 );
1444 }
1445 Ok(out)
1446}
1447
1448fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1454 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1455 return;
1456 }
1457 if comment.path.is_none() && is_noise(&comment.body) {
1458 return;
1459 }
1460 out.push(comment);
1461}
1462
1463pub fn is_noise(body: &str) -> bool {
1481 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1482 return true;
1483 }
1484 let mut content = false;
1485 for line in strip_blocks(body).lines() {
1486 let line = unquote(line);
1487 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1488 continue;
1489 }
1490 content = true;
1491 break;
1492 }
1493 !content
1494}
1495
1496fn strip_blocks(body: &str) -> String {
1498 let mut out = String::with_capacity(body.len());
1499 let mut rest = body;
1500 loop {
1501 let open = ["<!--", "<details>"]
1502 .iter()
1503 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1504 .min_by_key(|(i, _)| *i);
1505 let Some((at, tag)) = open else {
1506 out.push_str(rest);
1507 return out;
1508 };
1509 out.push_str(&rest[..at]);
1510 let after = &rest[at + tag.len()..];
1511 let close = if tag == "<!--" { "-->" } else { "</details>" };
1512 match after.find(close) {
1513 Some(end) => rest = &after[end + close.len()..],
1514 None => return out,
1516 }
1517 }
1518}
1519
1520fn unquote(line: &str) -> &str {
1522 let mut s = line.trim();
1523 while let Some(rest) = s.strip_prefix('>') {
1524 s = rest.trim_start();
1525 }
1526 s.trim()
1527}
1528
1529fn is_checklist(line: &str) -> bool {
1531 let rest = line
1532 .strip_prefix("- ")
1533 .or_else(|| line.strip_prefix("* "))
1534 .unwrap_or("");
1535 let rest = rest.trim_start();
1536 matches!(
1537 rest.get(..3),
1538 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1539 )
1540}
1541
1542fn is_decoration(line: &str) -> bool {
1544 line.starts_with('#')
1545 || line.starts_with("[!")
1546 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1547}
1548
1549fn is_banner(line: &str) -> bool {
1556 let plain = drop_spans(line, "**", "**");
1557 let plain = if plain.contains("](") {
1558 drop_spans(&plain, "[", ")")
1559 } else {
1560 plain
1561 };
1562 !plain.chars().any(char::is_alphanumeric)
1563}
1564
1565fn drop_spans(s: &str, open: &str, close: &str) -> String {
1569 let mut out = String::with_capacity(s.len());
1570 let mut rest = s;
1571 while let Some(at) = rest.find(open) {
1572 out.push_str(&rest[..at]);
1573 let after = &rest[at + open.len()..];
1574 match after.find(close) {
1575 Some(end) => rest = &after[end + close.len()..],
1576 None => return out,
1577 }
1578 }
1579 out.push_str(rest);
1580 out
1581}
1582
1583fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
1601 static LOCKS: std::sync::LazyLock<
1602 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
1603 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
1604 LOCKS
1605 .lock()
1606 .unwrap_or_else(std::sync::PoisonError::into_inner)
1607 .entry(repo.to_path_buf())
1608 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1609 .clone()
1610}
1611
1612pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1619 let repo = state.repo.clone();
1620 let budget = state.config.graph.land_rounds;
1621 let mut round = 0usize;
1622 let mut rebases = 0usize;
1625 let mut waited = Duration::ZERO;
1626 let mut shown: BTreeSet<String> = BTreeSet::new();
1631
1632 state.status = RunStatus::Landing;
1640 state.event("land", format!("watching {pr_url}"));
1641 state.save()?;
1642
1643 loop {
1644 let seen = observe(&repo, pr_url).await?;
1645 let mut pr = seen.pr;
1646 pr.review_comments.retain(|c| !shown.contains(&c.body));
1647 state.pr = Some(crate::run::PrRecord {
1648 url: pr.url.clone(),
1649 number: pr.number,
1650 state: pr.state.as_str().to_owned(),
1651 checks: pr.checks.as_str().to_owned(),
1652 round,
1653 rounds: budget,
1654 });
1655 state.save()?;
1656
1657 match decide(&pr, round, budget, waited) {
1658 Step::Wait => {
1659 if waited >= WAIT_CEILING {
1660 let why = format!(
1661 "checks were still running after {} minutes",
1662 WAIT_CEILING.as_secs() / 60
1663 );
1664 stop(state, &repo, &pr, &why).await?;
1665 return Ok(pr);
1666 }
1667 waited += POLL;
1668 tokio::time::sleep(POLL).await;
1669 }
1670 Step::Done { merged } => {
1671 state.status = if merged {
1672 RunStatus::Merged
1673 } else {
1674 RunStatus::Ready
1675 };
1676 let detail = if merged {
1677 format!("{} was merged", pr.url)
1678 } else {
1679 format!("{} was closed without merging", pr.url)
1680 };
1681 state.merge = Some(MergeOutcome {
1682 mode: MergeMode::Pr,
1683 ok: merged,
1684 detail: detail.clone(),
1685 });
1686 state.event("land", detail);
1687 state.save()?;
1688 return Ok(pr);
1689 }
1690 Step::Merge => {
1691 let subject = merge_subject(&seen.title, &state.instruction);
1692 if state.config.graph.land_approval {
1695 match approval_gate(state, &pr, &subject).await? {
1696 ApprovalGate::Approved => {}
1697 ApprovalGate::Held => {
1698 stop(
1699 state,
1700 &repo,
1701 &pr,
1702 "the owner did not approve the merge (held or unanswered)",
1703 )
1704 .await?;
1705 return Ok(pr);
1706 }
1707 ApprovalGate::Pending => {
1715 state.parked = true;
1716 state.event(
1717 "land",
1718 "parked awaiting merge approval - resumes once answered",
1719 );
1720 state.save()?;
1721 return Ok(pr);
1722 }
1723 }
1724 }
1725 let argv = merge_argv(pr.number, &subject);
1726 let out = {
1727 let merge_lock = repo_merge_lock(&repo);
1728 let _merge_slot = merge_lock.lock().await;
1729 gh(&repo, &argv).await?
1730 };
1731 if out.0 {
1732 pr.state = PrLifecycle::Merged;
1733 state.status = RunStatus::Merged;
1734 state.merge = Some(MergeOutcome {
1735 mode: MergeMode::Pr,
1736 ok: true,
1737 detail: format!("gh {}", argv.join(" ")),
1738 });
1739 if let Some(pr_record) = state.pr.as_mut() {
1744 pr_record.state = pr.state.as_str().to_owned();
1745 }
1746 state.event("land", format!("merged {} as `{subject}`", pr.url));
1747 state.save()?;
1748 return Ok(pr);
1749 }
1750 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
1751 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
1752 pr.state = PrLifecycle::Merged;
1753 state.status = RunStatus::Merged;
1754 state.merge = Some(outcome);
1755 if let Some(pr_record) = state.pr.as_mut() {
1756 pr_record.state = pr.state.as_str().to_owned();
1757 }
1758 state.event("land", format!("merged {} as `{subject}`", pr.url));
1759 state.save()?;
1760 return Ok(pr);
1761 }
1762 stop(
1763 state,
1764 &repo,
1765 &pr,
1766 &format!("`gh pr merge` failed: {}", out.1),
1767 )
1768 .await?;
1769 return Ok(pr);
1770 }
1771 Step::Rebase => {
1772 if rebases >= budget {
1778 let why = format!(
1779 "the base moved under this branch {budget} time(s) and it still does \
1780 not merge; rebasing again would only race it"
1781 );
1782 stop(state, &repo, &pr, &why).await?;
1783 return Ok(pr);
1784 }
1785 rebases += 1;
1786 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
1787 stop(
1788 state,
1789 &repo,
1790 &pr,
1791 "the pull request conflicts and this run has no winning branch to rebase",
1792 )
1793 .await?;
1794 return Ok(pr);
1795 };
1796 let base = state.base_branch.clone();
1797 state.event(
1798 "land",
1799 format!("{} no longer merges; rebasing onto {base}", pr.url),
1800 );
1801 state.save()?;
1802
1803 git::fetch(&repo, "origin", &base).await.ok();
1807 let scratch = state.dir().join("rebase");
1808 let onto = format!("origin/{base}");
1809 match git::rebase_branch_in_temp(&repo, &scratch, &branch, &onto).await {
1810 Ok(None) => {
1811 let pushed = {
1812 let merge_lock = repo_merge_lock(&repo);
1813 let _merge_slot = merge_lock.lock().await;
1814 git::push_rewritten(&repo, "origin", &branch).await?
1815 };
1816 if !pushed.ok() {
1817 let why = format!(
1818 "rebased {branch} but could not push it: {}",
1819 pushed.stderr.trim()
1820 );
1821 stop(state, &repo, &pr, &why).await?;
1822 return Ok(pr);
1823 }
1824 state.event("land", format!("rebased {branch} onto {base}"));
1825 state.save()?;
1826 waited = Duration::ZERO;
1829 tokio::time::sleep(POLL).await;
1830 }
1831 Ok(Some(conflict)) => {
1833 let why = format!(
1834 "{} conflicts with {base} and the rebase did not apply: {}",
1835 pr.url,
1836 conflict.chars().take(600).collect::<String>()
1837 );
1838 stop(state, &repo, &pr, &why).await?;
1839 return Ok(pr);
1840 }
1841 Err(e) => {
1842 let why = format!("could not rebase {branch} onto {base}: {e:#}");
1843 stop(state, &repo, &pr, &why).await?;
1844 return Ok(pr);
1845 }
1846 }
1847 }
1848 Step::GiveUp { reason } => {
1849 stop(state, &repo, &pr, &reason).await?;
1850 return Ok(pr);
1851 }
1852 Step::Fix { reason } => {
1853 round += 1;
1854 waited = Duration::ZERO;
1855 for c in &pr.review_comments {
1856 shown.insert(c.body.clone());
1857 }
1858 state.event("land", format!("round {round}: {reason}"));
1859 state.save()?;
1860
1861 let logs = failing_logs(&repo, &seen.failing_urls).await;
1862 let was_red = pr.checks == Checks::Red;
1863 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
1864 Fixed::Committed => {}
1865 Fixed::Declined if was_red => {
1866 let why = format!(
1867 "the fixer produced no commit while {} check(s) were failing \
1868 ({}); stopping instead of looping on an unchanged tree",
1869 pr.failing.len(),
1870 pr.failing.join(", ")
1871 );
1872 stop(state, &repo, &pr, &why).await?;
1873 return Ok(pr);
1874 }
1875 Fixed::Declined => state.event(
1880 "land",
1881 format!("round {round}: fixer declined the comments, nothing committed"),
1882 ),
1883 Fixed::Failed(why) => {
1884 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
1885 return Ok(pr);
1886 }
1887 }
1888 state.save()?;
1889 }
1890 }
1891 }
1892}
1893
1894struct Seen {
1898 pr: PrState,
1899 title: String,
1900 failing_urls: Vec<(String, String)>,
1901}
1902
1903async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
1906 let view = gh(
1907 repo,
1908 &[
1909 "pr".to_owned(),
1910 "view".to_owned(),
1911 pr_url.to_owned(),
1912 "--json".to_owned(),
1913 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
1914 ],
1915 )
1916 .await?;
1917 if !view.0 {
1918 bail!("gh pr view {pr_url}: {}", view.1);
1919 }
1920 let mut pr = parse_pr(&view.1)?;
1921 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
1922
1923 let inline = gh(
1924 repo,
1925 &[
1926 "api".to_owned(),
1927 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
1928 ],
1929 )
1930 .await?;
1931 if inline.0 {
1932 match parse_inline_comments(&inline.1) {
1933 Ok(mut comments) => pr.review_comments.append(&mut comments),
1934 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
1937 }
1938 } else {
1939 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
1940 }
1941
1942 let failing_urls = raw
1943 .status_check_rollup
1944 .iter()
1945 .filter(|c| c.verdict() == Verdict::Fail)
1946 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
1947 .collect();
1948
1949 Ok(Seen {
1950 pr,
1951 title: raw.title,
1952 failing_urls,
1953 })
1954}
1955
1956enum Fixed {
1958 Committed,
1960 Declined,
1962 Failed(String),
1964}
1965
1966async fn fix_round(
1972 state: &mut RunState,
1973 pr: &PrState,
1974 round: usize,
1975 budget: usize,
1976 reason: &str,
1977 logs: &str,
1978) -> Result<Fixed> {
1979 let winner = state
1980 .winner()
1981 .cloned()
1982 .context("landing needs a winning candidate; none is recorded on this run")?;
1983 let roles = state
1984 .config
1985 .resolve_roles()
1986 .context("resolve the roster for the fix round")?;
1987 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
1991 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1992 _ => (
1993 state
1994 .config
1995 .agent(&winner.agent)
1996 .cloned()
1997 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
1998 format!("impl-{}", winner.label),
1999 ),
2000 };
2001
2002 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
2003 let mut seat = seat_of(state, &seat_key, &spec.id);
2004 let artifacts = agent::artifacts_dir(&state.dir());
2005 let prompt = if state.config.cache_dir().is_some() {
2006 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
2007 } else {
2008 prompt
2009 };
2010 let out = agent::invoke(
2011 &spec,
2012 &mut seat,
2013 &Invocation {
2014 cwd: &winner.worktree,
2015 prompt: &prompt,
2016 timeout: Duration::from_secs(state.config.graph.timeout_fix),
2017 allow_write: true,
2018 sessions: state.config.graph.sessions,
2019 artifacts: &artifacts,
2020 stem: &format!("land-{round}"),
2021 run: &state.id,
2022 node: "land",
2023 cache_dir: state.config.cache_dir().as_deref(),
2024 attachments: &[],
2025 },
2026 )
2027 .await;
2028 state.seats.insert(seat.key.clone(), seat);
2029
2030 match out {
2031 Ok(o) if o.quota_exhausted() => {
2032 return Ok(Fixed::Failed(
2033 "rate limited (quota); the fixer could not run".to_owned(),
2034 ));
2035 }
2036 Ok(o) if !o.usable() => {
2037 return Ok(Fixed::Failed(format!(
2038 "the fixer produced nothing usable (exit {:?}, timed out: {})",
2039 o.exit_code, o.timed_out
2040 )));
2041 }
2042 Ok(_) => {}
2043 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
2044 }
2045
2046 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2047 if let Ok(r) = git::rescue_commit(
2050 &winner.worktree,
2051 &format!("magi: land round {round} fixes (uncommitted work)"),
2052 )
2053 .await
2054 {
2055 state.note_withheld("land", &r.withheld);
2056 }
2057 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2058 if after == before {
2059 return Ok(Fixed::Declined);
2060 }
2061
2062 let remote = state.config.merge.remote.clone();
2063 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
2064 if !push.ok() {
2065 return Ok(Fixed::Failed(format!(
2066 "pushing {} to {remote} failed: {}",
2067 winner.branch, push.stderr
2068 )));
2069 }
2070 state.event(
2071 "land",
2072 format!("round {round}: pushed a fix to {}", winner.branch),
2073 );
2074 Ok(Fixed::Committed)
2075}
2076
2077fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
2079 if let Some(existing) = state.seats.get(key)
2080 && existing.agent == agent
2081 {
2082 return existing.clone();
2083 }
2084 let fresh = SeatState::new(key, agent, state.seed);
2085 state.seats.insert(key.to_owned(), fresh.clone());
2086 fresh
2087}
2088
2089fn fix_prompt(
2091 state: &RunState,
2092 pr: &PrState,
2093 round: usize,
2094 budget: usize,
2095 reason: &str,
2096 logs: &str,
2097) -> String {
2098 let mut s = format!(
2099 "Your patch is open as a pull request and it is not landing. Land round \
2100 {round} of {budget}.\n\n\
2101 Pull request: {}\n\n\
2102 What is holding it: {reason}\n\n\
2103 # The task\n\n{}\n",
2104 pr.url, state.instruction
2105 );
2106
2107 if pr.failing.is_empty() {
2108 s.push_str("\n# Failing checks\n\n(none)\n");
2109 } else {
2110 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
2111 if logs.trim().is_empty() {
2112 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
2113 } else {
2114 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
2115 }
2116 }
2117
2118 if pr.review_comments.is_empty() {
2119 s.push_str("\n# Review comments\n\n(none)\n");
2120 } else {
2121 s.push_str("\n# Review comments\n");
2122 for c in &pr.review_comments {
2123 let where_ = match (&c.path, c.line) {
2124 (Some(p), Some(l)) => format!(" ({p}:{l})"),
2125 (Some(p), None) => format!(" ({p})"),
2126 _ => String::new(),
2127 };
2128 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
2129 }
2130 }
2131
2132 s.push_str(
2133 "\n# Rules\n\n\
2134 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
2135 failing test; do not silence a lint with an allow attribute; do not \
2136 stretch a timeout to hide a race. If the check is right, the code is \
2137 wrong.\n\
2138 2. Change nothing the checks and the comments did not raise. A \
2139 drive-by refactor turns a one-line fix into a pull request that \
2140 needs reviewing again.\n\
2141 3. If a comment is wrong, say so with a checkable argument and change \
2142 nothing for it. A declined comment with a reason is a correct \
2143 outcome; a change made to appease a reviewer is not.\n\
2144 4. Commit in this worktree. magi pushes to the pull request's branch \
2145 for you; do not push, merge, or close anything yourself.\n\
2146 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
2147 # Output\n\n\
2148 Say what you changed and why, and what you declined and why.",
2149 );
2150
2151 let language = &state.config.graph.language;
2152 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
2153 let _ = write!(s, "\n\nWrite all prose in {language}.");
2154 }
2155 s.push_str(&crate::prompt::github_english(language));
2157 if let Some(overlay) = state.config.prompts.overlay("fix") {
2158 let _ = write!(s, "\n\n{overlay}");
2159 }
2160 s
2161}
2162
2163async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
2166 let mut out = String::new();
2167 for (name, url) in failing.iter().take(MAX_LOGS) {
2168 let args = match (job_of(url), run_of(url)) {
2169 (Some(job), _) => vec![
2170 "run".to_owned(),
2171 "view".to_owned(),
2172 "--log-failed".to_owned(),
2173 "--job".to_owned(),
2174 job,
2175 ],
2176 (None, Some(run)) => vec![
2177 "run".to_owned(),
2178 "view".to_owned(),
2179 run,
2180 "--log-failed".to_owned(),
2181 ],
2182 (None, None) => continue,
2184 };
2185 let (ok, body) = match gh(repo, &args).await {
2186 Ok(v) => v,
2187 Err(e) => (false, format!("{e:#}")),
2188 };
2189 if !ok && body.trim().is_empty() {
2190 continue;
2191 }
2192 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
2193 }
2194 out
2195}
2196
2197fn job_of(details_url: &str) -> Option<String> {
2200 let after = details_url.split("/job/").nth(1)?;
2201 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2202 (!id.is_empty()).then_some(id)
2203}
2204
2205fn run_of(details_url: &str) -> Option<String> {
2207 let after = details_url.split("/actions/runs/").nth(1)?;
2208 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2209 (!id.is_empty()).then_some(id)
2210}
2211
2212fn stop_comment(run_id: &str, why: &str) -> String {
2216 format!(
2217 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
2218 The branch is untouched and the run is `{run_id}`. Nothing was merged."
2219 )
2220}
2221
2222async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
2227 let body = stop_comment(&state.id, why);
2228 let posted = gh(
2229 repo,
2230 &[
2231 "pr".to_owned(),
2232 "comment".to_owned(),
2233 pr.number.to_string(),
2234 "--body".to_owned(),
2235 body,
2236 ],
2237 )
2238 .await;
2239 match posted {
2240 Ok((true, _)) => {}
2241 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
2242 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
2243 }
2244 state.status = RunStatus::Blocked;
2245 state.merge = Some(MergeOutcome {
2246 mode: MergeMode::Pr,
2247 ok: false,
2248 detail: why.to_owned(),
2249 });
2250 state.event("land", format!("stopped: {why}"));
2251 state.save()?;
2252 Ok(())
2253}
2254
2255async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
2260 let out = tokio::process::Command::new("gh")
2261 .args(args)
2262 .current_dir(cwd)
2263 .quiet()
2264 .stdin(std::process::Stdio::null())
2265 .output()
2266 .await
2267 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
2268 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
2269 let err = String::from_utf8_lossy(&out.stderr);
2270 if body.trim().is_empty() {
2271 body = err.into_owned();
2272 } else if !err.trim().is_empty() {
2273 body.push_str(&err);
2274 }
2275 Ok((out.status.success(), body.trim().to_owned()))
2276}
2277
2278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2280enum Verdict {
2281 Pass,
2282 Fail,
2283 Pending,
2284 Unknown,
2285}
2286
2287#[derive(Debug, Deserialize)]
2288#[serde(rename_all = "camelCase")]
2289struct GhPr {
2290 #[serde(default)]
2291 url: String,
2292 #[serde(default)]
2293 number: u64,
2294 #[serde(default)]
2295 state: String,
2296 #[serde(default)]
2297 title: String,
2298 #[serde(default)]
2299 status_check_rollup: Vec<GhCheck>,
2300 #[serde(default)]
2307 merge_state_status: String,
2308 #[serde(default)]
2309 reviews: Vec<GhReview>,
2310 #[serde(default)]
2311 comments: Vec<GhComment>,
2312}
2313
2314#[derive(Debug, Deserialize)]
2319#[serde(rename_all = "camelCase")]
2320struct GhCheck {
2321 #[serde(default)]
2322 name: Option<String>,
2323 #[serde(default)]
2324 context: Option<String>,
2325 #[serde(default)]
2326 status: Option<String>,
2327 #[serde(default)]
2328 conclusion: Option<String>,
2329 #[serde(default)]
2330 state: Option<String>,
2331 #[serde(default)]
2332 details_url: Option<String>,
2333 #[serde(default)]
2334 target_url: Option<String>,
2335}
2336
2337impl GhCheck {
2338 fn label(&self) -> String {
2340 self.name
2341 .clone()
2342 .or_else(|| self.context.clone())
2343 .unwrap_or_else(|| "(unnamed check)".to_owned())
2344 }
2345
2346 fn url(&self) -> Option<&str> {
2348 self.details_url
2349 .as_deref()
2350 .or(self.target_url.as_deref())
2351 .filter(|u| !u.is_empty())
2352 }
2353
2354 fn verdict(&self) -> Verdict {
2362 if let Some(status) = self.status.as_deref() {
2363 if !status.eq_ignore_ascii_case("COMPLETED") {
2364 return Verdict::Pending;
2365 }
2366 }
2367 let outcome = self
2368 .conclusion
2369 .as_deref()
2370 .or(self.state.as_deref())
2371 .unwrap_or("");
2372 match outcome.to_ascii_uppercase().as_str() {
2373 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
2374 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
2375 | "ACTION_REQUIRED" => Verdict::Fail,
2376 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
2377 Verdict::Pending
2378 }
2379 _ => Verdict::Unknown,
2380 }
2381 }
2382}
2383
2384#[derive(Debug, Deserialize)]
2385struct GhAuthor {
2386 #[serde(default)]
2387 login: String,
2388}
2389
2390#[derive(Debug, Deserialize)]
2391struct GhReview {
2392 #[serde(default)]
2393 author: GhAuthor,
2394 #[serde(default)]
2395 body: String,
2396}
2397
2398#[derive(Debug, Deserialize)]
2399struct GhComment {
2400 #[serde(default)]
2401 author: GhAuthor,
2402 #[serde(default)]
2403 body: String,
2404}
2405
2406#[derive(Debug, Deserialize)]
2407struct GhUser {
2408 #[serde(default)]
2409 login: String,
2410}
2411
2412#[derive(Debug, Deserialize)]
2413struct GhInline {
2414 #[serde(default)]
2415 user: GhUser,
2416 #[serde(default)]
2417 path: Option<String>,
2418 #[serde(default)]
2419 line: Option<u64>,
2420 #[serde(default)]
2421 body: String,
2422}
2423
2424impl Default for GhAuthor {
2425 fn default() -> Self {
2426 Self {
2427 login: "(unknown)".to_owned(),
2428 }
2429 }
2430}
2431
2432impl Default for GhUser {
2433 fn default() -> Self {
2434 Self {
2435 login: "(unknown)".to_owned(),
2436 }
2437 }
2438}
2439
2440#[cfg(test)]
2441mod tests {
2442 use super::*;
2443 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
2444
2445 const GREEN_OPEN: &str = r####"{
2447 "url": "https://github.com/yukimemi/magi/pull/10",
2448 "number": 10,
2449 "state": "OPEN",
2450 "mergeStateStatus": "CLEAN",
2451 "statusCheckRollup": [
2452 {
2453 "__typename": "CheckRun",
2454 "conclusion": "SKIPPED",
2455 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2456 "name": "review",
2457 "status": "COMPLETED",
2458 "workflowName": "claude-review"
2459 },
2460 {
2461 "__typename": "CheckRun",
2462 "conclusion": "SUCCESS",
2463 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2464 "name": "check (ubuntu-latest)",
2465 "status": "COMPLETED",
2466 "workflowName": "CI"
2467 },
2468 {
2469 "__typename": "CheckRun",
2470 "conclusion": "SUCCESS",
2471 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2472 "name": "rustfmt",
2473 "status": "COMPLETED",
2474 "workflowName": "CI"
2475 },
2476 {
2477 "__typename": "StatusContext",
2478 "context": "CodeRabbit",
2479 "state": "SUCCESS",
2480 "targetUrl": ""
2481 }
2482 ],
2483 "reviews": [],
2484 "comments": [
2485 {
2486 "author": {
2487 "login": "coderabbitai"
2488 },
2489 "authorAssociation": "NONE",
2490 "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"
2491 }
2492 ]
2493}"####;
2494
2495 const RED_OPEN: &str = r####"{
2497 "url": "https://github.com/yukimemi/magi/pull/9",
2498 "number": 9,
2499 "state": "OPEN",
2500 "mergeStateStatus": "UNSTABLE",
2501 "statusCheckRollup": [
2502 {
2503 "__typename": "CheckRun",
2504 "conclusion": "SUCCESS",
2505 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2506 "name": "check (ubuntu-latest)",
2507 "status": "COMPLETED",
2508 "workflowName": "CI"
2509 },
2510 {
2511 "__typename": "CheckRun",
2512 "conclusion": "SUCCESS",
2513 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2514 "name": "rustfmt",
2515 "status": "COMPLETED",
2516 "workflowName": "CI"
2517 },
2518 {
2519 "__typename": "CheckRun",
2520 "conclusion": "FAILURE",
2521 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2522 "name": "editorconfig",
2523 "status": "COMPLETED",
2524 "workflowName": "CI"
2525 },
2526 {
2527 "__typename": "StatusContext",
2528 "context": "CodeRabbit",
2529 "state": "SUCCESS",
2530 "targetUrl": ""
2531 }
2532 ],
2533 "reviews": [],
2534 "comments": [
2535 {
2536 "author": {
2537 "login": "coderabbitai"
2538 },
2539 "authorAssociation": "NONE",
2540 "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"
2541 }
2542 ]
2543}"####;
2544
2545 const PENDING_OPEN: &str = r####"{
2547 "url": "https://github.com/yukimemi/magi/pull/9",
2548 "number": 9,
2549 "state": "OPEN",
2550 "statusCheckRollup": [
2551 {
2552 "__typename": "CheckRun",
2553 "conclusion": "SUCCESS",
2554 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2555 "name": "check (ubuntu-latest)",
2556 "status": "COMPLETED",
2557 "workflowName": "CI"
2558 },
2559 {
2560 "__typename": "CheckRun",
2561 "conclusion": "SUCCESS",
2562 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2563 "name": "rustfmt",
2564 "status": "COMPLETED",
2565 "workflowName": "CI"
2566 },
2567 {
2568 "__typename": "CheckRun",
2569 "conclusion": null,
2570 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2571 "name": "editorconfig",
2572 "status": "IN_PROGRESS",
2573 "workflowName": "CI"
2574 },
2575 {
2576 "__typename": "StatusContext",
2577 "context": "CodeRabbit",
2578 "state": "SUCCESS",
2579 "targetUrl": ""
2580 }
2581 ],
2582 "reviews": [],
2583 "comments": []
2584}"####;
2585
2586 const MERGED: &str = r####"{
2588 "url": "https://github.com/yukimemi/magi/pull/16",
2589 "number": 16,
2590 "state": "MERGED",
2591 "statusCheckRollup": [
2592 {
2593 "__typename": "CheckRun",
2594 "conclusion": "SUCCESS",
2595 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2596 "name": "check (ubuntu-latest)",
2597 "status": "COMPLETED",
2598 "workflowName": "CI"
2599 },
2600 {
2601 "__typename": "CheckRun",
2602 "conclusion": "SUCCESS",
2603 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2604 "name": "review",
2605 "status": "COMPLETED",
2606 "workflowName": "claude-review"
2607 }
2608 ],
2609 "reviews": [],
2610 "comments": []
2611}"####;
2612
2613 const REVIEWED_OPEN: &str = r####"{
2615 "url": "https://github.com/yukimemi/magi/pull/12",
2616 "number": 12,
2617 "state": "OPEN",
2618 "statusCheckRollup": [
2619 {
2620 "__typename": "CheckRun",
2621 "conclusion": "SUCCESS",
2622 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2623 "name": "check (ubuntu-latest)",
2624 "status": "COMPLETED",
2625 "workflowName": "CI"
2626 },
2627 {
2628 "__typename": "CheckRun",
2629 "conclusion": "SUCCESS",
2630 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2631 "name": "review",
2632 "status": "COMPLETED",
2633 "workflowName": "claude-review"
2634 }
2635 ],
2636 "reviews": [
2637 {
2638 "author": {
2639 "login": "claude"
2640 },
2641 "state": "COMMENTED",
2642 "body": ""
2643 }
2644 ],
2645 "comments": [
2646 {
2647 "author": {
2648 "login": "coderabbitai"
2649 },
2650 "authorAssociation": "NONE",
2651 "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"
2652 },
2653 {
2654 "author": {
2655 "login": "claude"
2656 },
2657 "authorAssociation": "NONE",
2658 "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"
2659 }
2660 ]
2661}"####;
2662
2663 const INLINE: &str = r####"[
2665 {
2666 "user": {
2667 "login": "claude[bot]"
2668 },
2669 "path": "src/graph.rs",
2670 "line": 231,
2671 "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"
2672 }
2673]"####;
2674
2675 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
2677<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
2678
2679> [!IMPORTANT]
2680> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
2681>
2682> This repository does not receive automatic reviews because it has fewer than 10 stars.
2683>
2684> <details>
2685> <summary>⚙️ Run configuration</summary>
2686>
2687> **Configuration used**: defaults
2688>
2689> **Review profile**: CHILL
2690>
2691> **Plan**: Team
2692>
2693> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
2694>
2695> </details>
2696
2697<!-- end of auto-generated comment: skip review by coderabbit.ai -->
2698
2699<!-- tips_start -->
2700
2701---
2702
2703Thanks 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.
2704
2705<details>
2706<summary>❤️ Share</summary>
2707
2708- [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"####;
2709
2710 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
2712
2713---
2714### Reviewing PR #16
2715
2716- [x] Read AGENTS.md conventions
2717- [x] Review `src/daemon.rs` changes
2718- [x] Review `src/main.rs` changes (new `doctor` reporting)
2719- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
2720- [x] Check test coverage for new behavior
2721- [x] Run verification commands (blocked — see note)
2722- [x] Post findings"####;
2723
2724 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
2726
2727---
2728### Review: `magi review <branch>` — cheap-half-only graph
2729
2730Read 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.
2731
2732**Correctness**
2733
2734- 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"####;
2735
2736 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
2737 PrState {
2738 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
2739 number: 16,
2740 state: PrLifecycle::Open,
2741 checks,
2742 blocking: if matches!(checks, Checks::Red) {
2746 Blocking::Yes
2747 } else {
2748 Blocking::No
2749 },
2750 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
2751 review_comments: (0..comments)
2752 .map(|i| ReviewComment {
2753 author: "coderabbitai".to_owned(),
2754 path: Some("src/graph.rs".to_owned()),
2755 line: Some(231),
2756 body: format!("finding {i}"),
2757 })
2758 .collect(),
2759 }
2760 }
2761
2762 #[test]
2763 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
2764 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
2765 assert_eq!(state.number, 10);
2766 assert_eq!(state.state, PrLifecycle::Open);
2767 assert_eq!(state.checks, Checks::Green);
2768 assert!(state.failing.is_empty());
2769 assert!(
2770 state.review_comments.is_empty(),
2771 "the only comment is CodeRabbit's trigger notice: {:?}",
2772 state.review_comments
2773 );
2774 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
2775 }
2776
2777 #[test]
2778 fn a_failing_check_parses_as_red_and_is_named() {
2779 let state = parse_pr(RED_OPEN).expect("red fixture parses");
2780 assert_eq!(state.checks, Checks::Red);
2781 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
2782 let mut blocking = state.clone();
2789 blocking.blocking = Blocking::Yes;
2790 match decide(&blocking, 0, 4, Duration::ZERO) {
2791 Step::Fix { reason } => {
2792 assert!(reason.contains("editorconfig"), "reason: {reason}");
2793 assert!(reason.contains("failing"), "reason: {reason}");
2794 }
2795 other => panic!("expected a fix round, got {other:?}"),
2796 }
2797 }
2798
2799 #[test]
2800 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
2801 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
2802 assert_eq!(state.checks, Checks::Pending);
2803 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
2804 }
2805
2806 #[test]
2807 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
2808 let state = parse_pr(MERGED).expect("merged fixture parses");
2809 assert_eq!(state.state, PrLifecycle::Merged);
2810 assert_eq!(
2811 decide(&state, 0, 4, Duration::ZERO),
2812 Step::Done { merged: true }
2813 );
2814 }
2815
2816 #[test]
2817 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
2818 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
2819 assert_eq!(state.checks, Checks::Green);
2820 let authors: Vec<&str> = state
2821 .review_comments
2822 .iter()
2823 .map(|c| c.author.as_str())
2824 .collect();
2825 assert_eq!(
2826 authors,
2827 vec!["claude"],
2828 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
2829 );
2830 match decide(&state, 0, 4, Duration::ZERO) {
2831 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
2832 other => panic!("expected a fix round, got {other:?}"),
2833 }
2834 }
2835
2836 #[test]
2837 fn inline_review_comments_keep_their_file_and_line() {
2838 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
2839 assert_eq!(comments.len(), 1);
2840 assert_eq!(comments[0].author, "claude[bot]");
2841 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
2842 assert_eq!(comments[0].line, Some(231));
2843 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
2844 }
2845
2846 #[test]
2847 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
2848 assert!(
2849 is_noise(CODERABBIT_TRIGGER),
2850 "CodeRabbit's trigger notice declares itself not a review"
2851 );
2852 assert!(
2853 is_noise(CLAUDE_CHECKLIST),
2854 "a progress checklist asks for nothing"
2855 );
2856 assert!(
2857 !is_noise(CLAUDE_FINDING),
2858 "a review that names a bug is input, not noise"
2859 );
2860
2861 let mut clean = pr(Checks::Green, &[], 0);
2862 clean.review_comments.push(ReviewComment {
2863 author: "coderabbitai".to_owned(),
2864 path: None,
2865 line: None,
2866 body: CODERABBIT_TRIGGER.to_owned(),
2867 });
2868 clean.review_comments.retain(|c| !is_noise(&c.body));
2869 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
2870
2871 let mut found = pr(Checks::Green, &[], 0);
2872 found.review_comments.push(ReviewComment {
2873 author: "claude".to_owned(),
2874 path: None,
2875 line: None,
2876 body: CLAUDE_FINDING.to_owned(),
2877 });
2878 found.review_comments.retain(|c| !is_noise(&c.body));
2879 assert!(matches!(
2880 decide(&found, 0, 4, Duration::ZERO),
2881 Step::Fix { .. }
2882 ));
2883 }
2884
2885 #[test]
2886 fn the_policy_table_holds_for_every_combination_that_matters() {
2887 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
2888 (
2889 "pending checks are waited for, even on the last round",
2890 pr(Checks::Pending, &[], 0),
2891 4,
2892 4,
2893 Duration::ZERO,
2894 Step::Wait,
2895 ),
2896 (
2897 "red checks are fixed",
2898 pr(Checks::Red, &["editorconfig"], 0),
2899 0,
2900 4,
2901 Duration::ZERO,
2902 Step::Fix {
2903 reason: "1 check(s) failing: editorconfig".to_owned(),
2904 },
2905 ),
2906 (
2907 "green with comments is fixed, not merged",
2908 pr(Checks::Green, &[], 2),
2909 1,
2910 4,
2911 Duration::ZERO,
2912 Step::Fix {
2913 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
2914 .to_owned(),
2915 },
2916 ),
2917 (
2918 "green and clean merges",
2919 pr(Checks::Green, &[], 0),
2920 3,
2921 4,
2922 Duration::ZERO,
2923 Step::Merge,
2924 ),
2925 (
2926 "an unreadable rollup is waited on while the grace lasts",
2927 pr(Checks::Unknown, &[], 0),
2928 0,
2929 4,
2930 Duration::ZERO,
2931 Step::Wait,
2932 ),
2933 (
2934 "an unreadable rollup is never merged once the grace is spent",
2935 pr(Checks::Unknown, &[], 0),
2936 0,
2937 4,
2938 CHECKS_GRACE,
2939 Step::GiveUp {
2940 reason: "no check status is readable on the pull request after 3 minute(s); \
2941 refusing to merge on a guess"
2942 .to_owned(),
2943 },
2944 ),
2945 ];
2946 for (what, state, round, budget, waited, want) in cases {
2947 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
2948 }
2949 }
2950
2951 #[test]
2952 fn the_forge_verdict_survives_the_round_trip_from_gh() {
2953 let green = parse_pr(GREEN_OPEN).expect("parse");
2957 assert_eq!(green.blocking, Blocking::No);
2958 let red = parse_pr(RED_OPEN).expect("parse");
2959 assert_eq!(
2960 red.blocking,
2961 Blocking::No,
2962 "`UNSTABLE` is mergeable: the red check is one nobody requires"
2963 );
2964 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
2965 let quiet =
2967 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
2968 assert_eq!(quiet.blocking, Blocking::Unsaid);
2969 }
2970
2971 #[test]
2972 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
2973 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
2979 nonblocking.blocking = Blocking::No;
2980 assert_eq!(
2981 decide(&nonblocking, 0, 4, Duration::ZERO),
2982 Step::Merge,
2983 "the forge says nothing is in the way, so nothing is"
2984 );
2985
2986 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
2988 blocking.blocking = Blocking::Yes;
2989 assert!(matches!(
2990 decide(&blocking, 0, 4, Duration::ZERO),
2991 Step::Fix { .. }
2992 ));
2993
2994 let mut commented = pr(Checks::Red, &["coverage"], 1);
2997 commented.blocking = Blocking::No;
2998 assert!(matches!(
2999 decide(&commented, 0, 4, Duration::ZERO),
3000 Step::Fix { .. }
3001 ));
3002
3003 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
3005 unsaid.blocking = Blocking::Unsaid;
3006 assert!(matches!(
3007 decide(&unsaid, 0, 4, Duration::ZERO),
3008 Step::Fix { .. }
3009 ));
3010 }
3011
3012 #[test]
3013 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
3014 let mut conflicted = pr(Checks::Green, &[], 0);
3019 conflicted.blocking = Blocking::Conflict;
3020 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
3021
3022 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
3026 red.blocking = Blocking::Conflict;
3027 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
3028
3029 let mut merged = pr(Checks::Red, &[], 0);
3031 merged.blocking = Blocking::Conflict;
3032 merged.state = PrLifecycle::Merged;
3033 assert_eq!(
3034 decide(&merged, 0, 4, Duration::ZERO),
3035 Step::Done { merged: true }
3036 );
3037 }
3038
3039 #[test]
3040 fn the_forge_verdict_is_read_off_merge_state_status() {
3041 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
3044 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
3045 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
3046 }
3047 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
3048 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
3049 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
3050 for quiet in ["", "UNKNOWN"] {
3053 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
3054 assert!(Blocking::of(quiet).stops_a_merge());
3055 }
3056 }
3057
3058 #[test]
3059 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
3060 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
3061 let jj = "could not determine current branch: failed to run git: not on any branch";
3063
3064 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
3065 .expect("the forge says merged, so it merged");
3066 assert!(landed.ok);
3067 assert!(
3068 landed.detail.contains("but the pull request is merged"),
3069 "the record must not read as a clean success: {}",
3070 landed.detail
3071 );
3072 assert!(
3073 landed.detail.contains("not on any branch"),
3074 "and it must keep what the command actually said: {}",
3075 landed.detail
3076 );
3077
3078 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
3080 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
3081 assert!(merged_after_all(&argv, jj, None).is_none());
3083 }
3084
3085 #[test]
3086 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
3087 let mut state = pr(Checks::Red, &["editorconfig"], 3);
3088 state.state = PrLifecycle::Closed;
3089 assert_eq!(
3090 decide(&state, 0, 4, Duration::ZERO),
3091 Step::Done { merged: false },
3092 "a human closing the pull request ends the loop, whatever CI says"
3093 );
3094 }
3095
3096 #[test]
3097 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
3098 let red = decide(
3099 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
3100 4,
3101 4,
3102 Duration::ZERO,
3103 );
3104 match red {
3105 Step::GiveUp { reason } => {
3106 assert!(reason.contains("editorconfig"), "reason: {reason}");
3107 assert!(reason.contains("test (macos)"), "reason: {reason}");
3108 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
3109 }
3110 other => panic!("expected a give-up, got {other:?}"),
3111 }
3112
3113 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
3114 match commented {
3115 Step::GiveUp { reason } => {
3116 assert!(reason.contains("unresolved"), "reason: {reason}");
3117 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
3118 }
3119 other => panic!("expected a give-up, got {other:?}"),
3120 }
3121 }
3122
3123 #[test]
3124 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
3125 let candidate_commit = "magi: candidate A (uncommitted work)";
3126 let subject = merge_subject(candidate_commit, "add retries to the uploader");
3127 let argv = merge_argv(16, &subject);
3128
3129 assert!(argv.contains(&"--squash".to_owned()));
3130 assert!(argv.contains(&"--delete-branch".to_owned()));
3131 assert!(argv.contains(&"--subject".to_owned()));
3132 assert_eq!(
3133 argv.last().map(String::as_str),
3134 Some("add retries to the uploader"),
3135 "the subject must not be the candidate commit message"
3136 );
3137 assert_ne!(subject, candidate_commit);
3138 }
3139
3140 #[test]
3141 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
3142 assert_eq!(
3143 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
3144 "feat: a queue, an unattended loop, and a phone UI"
3145 );
3146 assert_eq!(
3147 merge_subject("", "# port the retry logic\n\ndetails"),
3148 "port the retry logic",
3149 "an empty title falls back to the task's first line, heading marks stripped"
3150 );
3151 }
3152
3153 #[test]
3154 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
3155 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
3156 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
3157 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
3158 assert_eq!(job_of("https://coderabbit.ai/status"), None);
3159 assert_eq!(run_of(""), None);
3160 }
3161
3162 #[test]
3163 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
3164 let mut out = Vec::new();
3165 push_if_outstanding(
3166 &mut out,
3167 ReviewComment {
3168 author: "yukimemi".to_owned(),
3169 path: None,
3170 line: None,
3171 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
3172 },
3173 );
3174 assert!(out.is_empty());
3175 }
3176
3177 fn run_state() -> RunState {
3181 RunState::new(
3182 std::path::PathBuf::from("/repo/magi"),
3183 "main".to_owned(),
3184 "abcdef1234".to_owned(),
3185 "add retries to the uploader".to_owned(),
3186 crate::config::Config::default(),
3187 )
3188 }
3189
3190 fn green_pr() -> PrState {
3191 PrState {
3192 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
3193 number: 42,
3194 state: PrLifecycle::Open,
3195 checks: Checks::Green,
3196 blocking: Blocking::No,
3198 failing: Vec::new(),
3199 review_comments: vec![ReviewComment {
3200 author: "coderabbitai".to_owned(),
3201 path: Some("src/land.rs".to_owned()),
3202 line: Some(212),
3203 body: "this branch never checks the exit code".to_owned(),
3204 }],
3205 }
3206 }
3207
3208 #[test]
3209 fn github_facing_land_text_is_english_whatever_the_language() {
3210 let mut state = run_state();
3211 state.config.graph.language = "ja".to_owned();
3212 let comment = stop_comment(&state.id, "checks are still red");
3213 assert!(comment.is_ascii(), "{comment}");
3214 assert!(comment.starts_with(MARKER));
3215
3216 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3217 let ja_at = p.find("Write all prose in ja").unwrap();
3218 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
3219 assert!(ja_at < rule_at, "{p}");
3220 assert!(p.contains("stays in Japanese"), "{p}");
3221
3222 state.config.graph.language = "en".to_owned();
3223 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3224 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
3225 assert!(!p.contains("does not apply"), "{p}");
3226 }
3227
3228 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
3229
3230 fn panel() -> String {
3231 approval_panel(
3232 &run_state(),
3233 &green_pr(),
3234 NUMSTAT,
3235 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
3236 &[
3237 "land: ask before merging".to_owned(),
3238 "land: colour the diff".to_owned(),
3239 ],
3240 "feat: merge approval from the phone",
3241 )
3242 }
3243
3244 #[test]
3245 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
3246 let html = panel();
3247 for needle in [
3248 "42",
3249 "main",
3250 "src/land.rs",
3251 "src/web.rs",
3252 "assets/logo.png",
3253 "feat: merge approval from the phone",
3254 "land: ask before merging",
3255 "land: colour the diff",
3256 "coderabbitai",
3257 "this branch never checks the exit code",
3258 "green",
3259 ] {
3260 assert!(html.contains(needle), "the panel must state `{needle}`");
3261 }
3262 }
3263
3264 fn winning_candidate(summary: &str) -> Candidate {
3267 Candidate {
3268 index: 0,
3269 label: 'A',
3270 agent: "opus".to_owned(),
3271 branch: "magi/x/A".to_owned(),
3272 worktree: PathBuf::from("/wt/A"),
3273 summary: summary.to_owned(),
3274 stat: String::new(),
3275 files: 1,
3276 commits: 1,
3277 empty: false,
3278 failed: None,
3279 verified_noop: None,
3280 duration_ms: 0,
3281 folded: false,
3282 }
3283 }
3284
3285 fn uncontested_tally() -> Tally {
3286 Tally {
3287 first_choice: BTreeMap::from([('A', 1)]),
3288 borda: BTreeMap::new(),
3289 winner: 'A',
3290 rankings: 1,
3291 unanimous_initial: true,
3292 deliberated: false,
3293 changed_votes: 0,
3294 unanimous_final: true,
3295 tie_break: None,
3296 judges: 1,
3297 present: 1,
3298 quorum: 1,
3299 met_quorum: true,
3300 uncontested: None,
3301 }
3302 }
3303
3304 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
3305 ReviewRecord {
3306 attempts: 0,
3307 reviewer,
3308 agent: agent.to_owned(),
3309 summary: summary.to_owned(),
3310 findings: Vec::new(),
3311 vote: None,
3312 failed: None,
3313 duration_ms: 0,
3314 }
3315 }
3316
3317 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
3318 let answered = reviews.len();
3319 ReviewRound {
3320 round,
3321 head: "abc1234".to_owned(),
3322 verified_head: None,
3323 verified_at: None,
3324 reviews,
3325 e2e: Vec::new(),
3326 verify_retried: false,
3327 e2e_deferred: false,
3328 e2e_defer_reason: None,
3329 fix: None,
3330 blocking: 0,
3331 answered,
3332 expected: answered,
3333 clean: true,
3334 progressed: false,
3335 vote_split: false,
3336 reconsideration: Vec::new(),
3337 verdict: None,
3338 }
3339 }
3340
3341 #[test]
3342 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
3343 let en = panel();
3344 assert!(en.contains("Task"), "{en}");
3345 assert!(en.contains("add retries to the uploader"), "{en}");
3346
3347 let mut state = run_state();
3348 state.config.graph.language = "ja".to_owned();
3349 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3350 assert!(ja.contains("タスク"), "{ja}");
3351 assert!(
3352 ja.contains("add retries to the uploader"),
3353 "the task itself is not translated: {ja}"
3354 );
3355 }
3356
3357 #[test]
3358 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
3359 let html = panel();
3363 assert!(!html.contains("What changed"), "{html}");
3364 assert!(!html.contains("Review verdict"), "{html}");
3365 }
3366
3367 #[test]
3368 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
3369 let mut state = run_state();
3370 state.candidates = vec![winning_candidate("")];
3371 state.tally = Some(uncontested_tally());
3372 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3373 assert!(
3374 !html.contains("What changed"),
3375 "an empty summary must not render an empty box: {html}"
3376 );
3377 }
3378
3379 #[test]
3380 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
3381 let mut state = run_state();
3382 state.candidates = vec![winning_candidate(
3383 "Added a retry loop around the uploader PUT call.",
3384 )];
3385 state.tally = Some(uncontested_tally());
3386 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3387 assert!(en.contains("What changed"), "{en}");
3388 assert!(
3389 en.contains("Added a retry loop around the uploader PUT call."),
3390 "{en}"
3391 );
3392
3393 state.config.graph.language = "ja".to_owned();
3394 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3395 assert!(ja.contains("変更内容"), "{ja}");
3396 assert!(
3397 ja.contains("Added a retry loop around the uploader PUT call."),
3398 "{ja}"
3399 );
3400 }
3401
3402 #[test]
3403 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
3404 let mut state = run_state();
3405 state.reviews = vec![
3406 review_round(
3407 1,
3408 vec![review_record(1, "alpha", "found a race, sent back")],
3409 ),
3410 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
3411 ];
3412 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3413 assert!(en.contains("Review verdict"), "{en}");
3414 assert!(en.contains("race is fixed, clean"), "{en}");
3415 assert!(
3416 !en.contains("found a race, sent back"),
3417 "only the round that actually cleared the merge should show: {en}"
3418 );
3419
3420 state.config.graph.language = "ja".to_owned();
3421 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3422 assert!(ja.contains("レビューの結論"), "{ja}");
3423 assert!(ja.contains("レビュアー"), "{ja}");
3424 assert!(ja.contains("race is fixed, clean"), "{ja}");
3425 }
3426
3427 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
3433 ReviewRecord {
3434 attempts: 0,
3435 reviewer,
3436 agent: agent.to_owned(),
3437 summary: String::new(),
3438 findings: Vec::new(),
3439 vote: None,
3440 failed: Some(reason.to_owned()),
3441 duration_ms: 0,
3442 }
3443 }
3444
3445 #[test]
3446 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
3447 let mut state = run_state();
3448 state.reviews = vec![review_round(
3449 1,
3450 vec![
3451 review_record(1, "alpha", "clean, nothing to add"),
3452 unanswered_review_record(2, "beta", "timed out"),
3453 ],
3454 )];
3455 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3456 assert!(en.contains("clean, nothing to add"), "{en}");
3457 assert!(
3458 en.contains("produced no answer: timed out"),
3459 "a seat that never answered must say so, not render a blank box: {en}"
3460 );
3461 assert!(
3462 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
3463 "no reviewer box may be left empty: {en}"
3464 );
3465
3466 state.config.graph.language = "ja".to_owned();
3467 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3468 assert!(ja.contains("回答なし: timed out"), "{ja}");
3469 }
3470
3471 #[test]
3472 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
3473 let html = panel();
3474 assert!(!html.contains("<script"), "no script survives the csp");
3475 assert!(!html.contains("<form"), "form-action is 'none'");
3476 let pr = green_pr();
3477 assert_eq!(
3478 html.matches("http").count(),
3479 html.matches(pr.url.as_str()).count(),
3480 "the only http url in the panel is the pull request's own link"
3481 );
3482 }
3483
3484 #[test]
3485 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
3486 let html = panel();
3487 assert!(
3488 html.contains(">+</span>"),
3489 "an added line carries a `+` in the gutter, not only a background"
3490 );
3491 assert!(
3492 html.contains(">-</span>"),
3493 "a removed line carries a `-` in the gutter, not only a background"
3494 );
3495 assert!(
3496 html.contains(">new line</span>"),
3497 "the marker is moved to the gutter, so the body is printed once without it"
3498 );
3499 }
3500
3501 #[test]
3502 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
3503 let total = DIFF_MAX_LINES + 100;
3504 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
3505 let html = approval_panel(
3506 &run_state(),
3507 &green_pr(),
3508 NUMSTAT,
3509 &diff,
3510 &[],
3511 "feat: something long",
3512 );
3513 assert!(
3514 html.contains(&format!("100 of {total} diff lines omitted")),
3515 "the note must say exactly how much was cut"
3516 );
3517 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
3518 assert!(
3519 !html.contains(&format!("line {DIFF_MAX_LINES}")),
3520 "nothing past the threshold is rendered"
3521 );
3522 assert!(
3523 html.contains("/repo/magi"),
3524 "the note says where the rest is"
3525 );
3526 }
3527
3528 #[test]
3529 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
3530 let html = approval_panel(
3531 &run_state(),
3532 &green_pr(),
3533 "1\t2\tsrc/<b>&\"x\"'.rs",
3534 "",
3535 &[],
3536 "subject",
3537 );
3538 assert!(html.contains("src/<b>&"x"'.rs"));
3539 assert!(
3540 !html.contains("<b>"),
3541 "an agent-influenced path must never become markup"
3542 );
3543 }
3544
3545 #[tokio::test]
3546 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
3547 let a = std::path::PathBuf::from("/repo/a");
3548 let b = std::path::PathBuf::from("/repo/b");
3549
3550 let held = repo_merge_lock(&a).lock_owned().await;
3551
3552 assert!(
3555 repo_merge_lock(&a).try_lock().is_err(),
3556 "a second merge into the same repository must not proceed concurrently"
3557 );
3558
3559 assert!(
3563 repo_merge_lock(&b).try_lock().is_ok(),
3564 "a different repository's merge lock must be independent"
3565 );
3566
3567 drop(held);
3568 assert!(
3569 repo_merge_lock(&a).try_lock().is_ok(),
3570 "the lock is released once the holder is done"
3571 );
3572 }
3573
3574 #[test]
3575 fn only_the_merge_choice_merges_and_silence_holds() {
3576 let table = [
3577 (None, Approval::Hold),
3578 (Some("merge"), Approval::Merge),
3579 (Some(" merge\n"), Approval::Merge),
3580 (Some("hold"), Approval::Hold),
3581 (Some(""), Approval::Hold),
3582 (Some("yes"), Approval::Hold),
3583 ];
3584 for (answer, want) in table {
3585 assert_eq!(
3586 approval(answer),
3587 want,
3588 "answer {answer:?} must resolve to {want:?}"
3589 );
3590 }
3591 }
3592
3593 #[tokio::test]
3594 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
3595 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3596 let mut state = run_state();
3597 state.config.graph.land_approval = true;
3598 let pr = green_pr();
3599
3600 let gate = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3601 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
3602 assert!(
3603 !state.parked,
3604 "approval_gate itself never sets `parked`; only its caller does"
3605 );
3606
3607 let store = ask::Questions::open();
3608 let filed: Vec<_> = store
3609 .list()
3610 .into_iter()
3611 .filter(|q| q.run == state.id)
3612 .collect();
3613 assert_eq!(filed.len(), 1, "exactly one question is filed");
3614 assert_eq!(filed[0].node, APPROVAL_NODE);
3615 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
3616 assert!(filed[0].status.open());
3617
3618 let again = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3622 assert_eq!(again, ApprovalGate::Pending);
3623 let still_one = store
3624 .list()
3625 .into_iter()
3626 .filter(|q| q.run == state.id)
3627 .count();
3628 assert_eq!(
3629 still_one, 1,
3630 "asking twice must not double-file the question"
3631 );
3632 }
3633
3634 #[tokio::test]
3635 async fn approving_the_existing_question_is_read_back_as_approved() {
3636 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3637 let mut state = run_state();
3638 state.config.graph.land_approval = true;
3639 let pr = green_pr();
3640 assert_eq!(
3641 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3642 ApprovalGate::Pending
3643 );
3644
3645 let store = ask::Questions::open();
3646 let mut q = store
3647 .list()
3648 .into_iter()
3649 .find(|q| q.run == state.id)
3650 .expect("filed above");
3651 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
3652 store.put(&mut q).unwrap();
3653
3654 assert_eq!(
3655 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3656 ApprovalGate::Approved
3657 );
3658 }
3659
3660 #[tokio::test]
3661 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
3662 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3663 let store = ask::Questions::open();
3664
3665 let mut held_state = run_state();
3666 held_state.config.graph.land_approval = true;
3667 let pr = green_pr();
3668 approval_gate(&mut held_state, &pr, "feat: x")
3669 .await
3670 .unwrap();
3671 let mut q = store
3672 .list()
3673 .into_iter()
3674 .find(|q| q.run == held_state.id)
3675 .expect("filed above");
3676 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
3677 store.put(&mut q).unwrap();
3678 assert_eq!(
3679 approval_gate(&mut held_state, &pr, "feat: x")
3680 .await
3681 .unwrap(),
3682 ApprovalGate::Held
3683 );
3684
3685 let mut abandoned_state = run_state();
3686 abandoned_state.config.graph.land_approval = true;
3687 approval_gate(&mut abandoned_state, &pr, "feat: x")
3688 .await
3689 .unwrap();
3690 let mut q = store
3691 .list()
3692 .into_iter()
3693 .find(|q| q.run == abandoned_state.id)
3694 .expect("filed above");
3695 q.abandon("no answer within the timeout");
3696 store.put(&mut q).unwrap();
3697 assert_eq!(
3698 approval_gate(&mut abandoned_state, &pr, "feat: x")
3699 .await
3700 .unwrap(),
3701 ApprovalGate::Held,
3702 "silence must never merge"
3703 );
3704 }
3705
3706 #[test]
3707 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
3708 let rows = parse_numstat(NUMSTAT);
3709 assert_eq!(
3710 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
3711 ["src/web.rs", "src/land.rs", "assets/logo.png"]
3712 );
3713 assert_eq!(rows[2].added, None, "a binary file has no line counts");
3714 }
3715 #[test]
3716 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
3717 let mut state = run_state();
3721 state.config.graph.language = "ja".to_owned();
3722 let pr = green_pr();
3723 let commits = ["c1".to_owned()];
3724
3725 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3726 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
3727 assert!(ja.contains("squash されるコミット"), "{ja}");
3728 assert!(ja.contains("レビューコメント"), "{ja}");
3729 assert!(ja.contains("差分"), "{ja}");
3730 assert!(
3731 !ja.contains("Commits being squashed"),
3732 "no English left over"
3733 );
3734
3735 let w = words("ja");
3736 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
3737 assert!(
3738 w.approval_detail("http://x/1", "main", "feat: x")
3739 .contains("パネル")
3740 );
3741
3742 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
3744 assert!(ja.contains("feat: x"), "nor is the merge subject");
3745
3746 state.config.graph.language = "en".to_owned();
3749 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3750 assert!(en.contains("Commits being squashed"), "{en}");
3751 assert_eq!(words("Klingon").html_lang, "en");
3752 }
3753
3754 #[test]
3758 fn pick_merged_pr_picks_the_unique_match() {
3759 let json = r#"[
3760 {"url": "https://github.com/o/r/pull/42", "number": 42,
3761 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
3762 ]"#;
3763 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3764 let found = pick_merged_pr(json, "main", created_at)
3765 .expect("valid json")
3766 .expect("one unambiguous match");
3767 assert_eq!(found.url, "https://github.com/o/r/pull/42");
3768 assert_eq!(found.number, 42);
3769 }
3770
3771 #[test]
3775 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
3776 let json = r#"[
3777 {"url": "https://github.com/o/r/pull/42", "number": 42,
3778 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
3779 {"url": "https://github.com/o/r/pull/43", "number": 43,
3780 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
3781 ]"#;
3782 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3783 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3784 }
3785
3786 #[test]
3790 fn pick_merged_pr_ignores_a_different_base_branch() {
3791 let json = r#"[
3792 {"url": "https://github.com/o/r/pull/42", "number": 42,
3793 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
3794 ]"#;
3795 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3796 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3797 }
3798
3799 #[test]
3802 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
3803 let json = r#"[
3804 {"url": "https://github.com/o/r/pull/42", "number": 42,
3805 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
3806 ]"#;
3807 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3808 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3809 }
3810
3811 #[tokio::test]
3816 async fn find_external_merge_returns_none_without_a_winner() {
3817 let state = RunState::new(
3818 PathBuf::from("/no/such/repo"),
3819 "main".to_owned(),
3820 "0000000000000000000000000000000000000000".to_owned(),
3821 "irrelevant".to_owned(),
3822 crate::config::Config::default(),
3823 );
3824 assert_eq!(find_external_merge(&state).await.unwrap(), None);
3825 }
3826}