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