1use std::collections::BTreeSet;
42use std::fmt::Write as _;
43use std::path::Path;
44use std::time::Duration;
45
46use anyhow::{Context as _, Result, bail};
47use serde::Deserialize;
48
49use crate::agent::{self, Invocation, SeatState};
50use crate::ask;
51use crate::config::{AgentSpec, MergeMode};
52use crate::git;
53use crate::run::{MergeOutcome, RunState, RunStatus, tail};
54
55pub const POLL: Duration = Duration::from_secs(30);
61
62pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
68
69pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
81
82const LOG_TAIL: usize = 4_000;
85
86const MAX_LOGS: usize = 3;
89
90pub const MARKER: &str = "<!-- magi:land -->";
96
97const NOT_A_REVIEW: [&str; 3] = [
106 "skip review by coderabbit.ai",
107 "summarize by coderabbit.ai",
108 "<!-- tips_start -->",
109];
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum PrLifecycle {
114 Open,
116 Merged,
118 Closed,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum Checks {
125 Pending,
127 Green,
130 Red,
132 Unknown,
134}
135
136impl PrLifecycle {
137 pub fn as_str(self) -> &'static str {
139 match self {
140 Self::Open => "open",
141 Self::Merged => "merged",
142 Self::Closed => "closed",
143 }
144 }
145}
146
147impl Checks {
148 pub fn as_str(self) -> &'static str {
150 match self {
151 Self::Pending => "pending",
152 Self::Green => "green",
153 Self::Red => "red",
154 Self::Unknown => "unknown",
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ReviewComment {
162 pub author: String,
164 pub path: Option<String>,
166 pub line: Option<u64>,
168 pub body: String,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PrState {
175 pub url: String,
177 pub number: u64,
179 pub state: PrLifecycle,
181 pub checks: Checks,
183 pub failing: Vec<String>,
185 pub review_comments: Vec<ReviewComment>,
187 pub blocking: Blocking,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum Blocking {
205 No,
207 Yes,
209 Conflict,
211 Unsaid,
215}
216
217impl Blocking {
218 fn of(raw: &str) -> Self {
220 match raw.to_ascii_uppercase().as_str() {
221 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
224 "DIRTY" => Self::Conflict,
225 "" | "UNKNOWN" => Self::Unsaid,
226 _ => Self::Yes,
228 }
229 }
230
231 #[must_use]
233 pub fn stops_a_merge(self) -> bool {
234 !matches!(self, Self::No)
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum Step {
241 Wait,
243 Rebase,
251 Fix {
253 reason: String,
255 },
256 Merge,
258 Done {
260 merged: bool,
262 },
263 GiveUp {
265 reason: String,
267 },
268}
269
270fn merged_after_all(
292 argv: &[String],
293 stderr: &str,
294 after: Option<PrLifecycle>,
295) -> Option<MergeOutcome> {
296 if after? != PrLifecycle::Merged {
297 return None;
298 }
299 Some(MergeOutcome {
300 mode: MergeMode::Pr,
301 ok: true,
302 detail: format!(
303 "gh {} (the command reported `{}`, but the pull request is merged)",
304 argv.join(" "),
305 stderr.trim()
306 ),
307 })
308}
309
310pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
330 match pr.state {
331 PrLifecycle::Merged => return Step::Done { merged: true },
332 PrLifecycle::Closed => return Step::Done { merged: false },
333 PrLifecycle::Open => {}
334 }
335
336 if pr.blocking == Blocking::Conflict {
339 return Step::Rebase;
340 }
341
342 let spent = round >= budget;
343 match pr.checks {
344 Checks::Pending => Step::Wait,
345 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
346 Checks::Unknown => Step::GiveUp {
347 reason: format!(
348 "no check status is readable on the pull request after {} minute(s); \
349 refusing to merge on a guess",
350 CHECKS_GRACE.as_secs() / 60
351 ),
352 },
353 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
360 Checks::Red => {
361 let what = format!(
362 "{} check(s) failing: {}",
363 pr.failing.len(),
364 pr.failing.join(", ")
365 );
366 if spent {
367 Step::GiveUp {
368 reason: format!("{what} — still red after {budget} fix round(s)"),
369 }
370 } else {
371 Step::Fix { reason: what }
372 }
373 }
374 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
375 Checks::Green => {
376 let what = format!(
377 "checks are green but {} review comment(s) are unresolved: {}",
378 pr.review_comments.len(),
379 authors(&pr.review_comments)
380 );
381 if spent {
382 Step::GiveUp {
383 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
384 }
385 } else {
386 Step::Fix { reason: what }
387 }
388 }
389 }
390}
391
392fn authors(comments: &[ReviewComment]) -> String {
394 let mut seen: Vec<&str> = Vec::new();
395 for c in comments {
396 if !seen.contains(&c.author.as_str()) {
397 seen.push(&c.author);
398 }
399 }
400 seen.join(", ")
401}
402
403pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
407 vec![
408 "pr".to_owned(),
409 "merge".to_owned(),
410 number.to_string(),
411 "--squash".to_owned(),
412 "--delete-branch".to_owned(),
413 "--subject".to_owned(),
414 subject.to_owned(),
415 ]
416}
417
418pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
425 let title = pr_title.trim();
426 if !title.is_empty() && !title.starts_with("magi: candidate") {
427 return title.to_owned();
428 }
429 let first = instruction
430 .lines()
431 .map(str::trim)
432 .find(|l| !l.is_empty())
433 .unwrap_or("magi: land the winning candidate");
434 first.trim_start_matches(['#', ' ']).to_owned()
435}
436
437pub const APPROVE: &str = "merge";
439
440pub const HOLD: &str = "hold";
442
443pub const APPROVAL_NODE: &str = "land-approval";
449
450pub const DIFF_MAX_LINES: usize = 400;
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum Approval {
462 Merge,
464 Hold,
466}
467
468pub fn approval(answer: Option<&str>) -> Approval {
476 match answer {
477 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
478 _ => Approval::Hold,
479 }
480}
481
482fn esc(s: &str) -> String {
492 let mut out = String::with_capacity(s.len());
493 for c in s.chars() {
494 match c {
495 '&' => out.push_str("&"),
496 '<' => out.push_str("<"),
497 '>' => out.push_str(">"),
498 '"' => out.push_str("""),
499 '\'' => out.push_str("'"),
500 _ => out.push(c),
501 }
502 }
503 out
504}
505
506#[derive(Debug, Clone, PartialEq, Eq)]
508struct StatRow {
509 path: String,
510 added: Option<u64>,
512 removed: Option<u64>,
513}
514
515impl StatRow {
516 fn churn(&self) -> u64 {
519 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
520 }
521}
522
523fn parse_numstat(numstat: &str) -> Vec<StatRow> {
529 let mut rows: Vec<StatRow> = numstat
530 .lines()
531 .filter_map(|line| {
532 let mut parts = line.splitn(3, '\t');
533 let added = parts.next()?.trim();
534 let removed = parts.next()?.trim();
535 let path = parts.next()?.trim();
536 if path.is_empty() {
537 return None;
538 }
539 Some(StatRow {
540 path: path.to_owned(),
541 added: added.parse().ok(),
542 removed: removed.parse().ok(),
543 })
544 })
545 .collect();
546 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
549 rows
550}
551
552fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
561 if line.starts_with("+++") || line.starts_with("---") {
562 (" ", "color:#57606a;font-weight:600", line)
563 } else if let Some(body) = line.strip_prefix('+') {
564 ("+", "background:#e6ffec;color:#0a3622", body)
565 } else if let Some(body) = line.strip_prefix('-') {
566 ("-", "background:#ffebe9;color:#5c1a17", body)
567 } else if line.starts_with("@@") {
568 ("~", "background:#eef2ff;color:#3730a3", line)
569 } else if let Some(body) = line.strip_prefix(' ') {
570 (" ", "", body)
571 } else {
572 (" ", "color:#57606a;font-weight:600", line)
573 }
574}
575
576struct Words {
585 html_lang: &'static str,
586 checks: &'static str,
587 nothing_failing: &'static str,
588 files_changed: &'static str,
589 commits: &'static str,
590 no_commits: &'static str,
591 comments: &'static str,
592 no_comments: &'static str,
593 diff: &'static str,
594 truncated: &'static str,
595 lands_as: &'static str,
596}
597
598const EN: Words = Words {
599 html_lang: "en",
600 checks: "Checks",
601 nothing_failing: "Nothing failing.",
602 files_changed: "file(s) changed",
603 commits: "Commits being squashed",
604 no_commits: "No commit subjects could be read from the branch.",
605 comments: "Review comments",
606 no_comments: "Nothing outstanding at this observation.",
607 diff: "Diff",
608 truncated: "Truncated",
609 lands_as: "They land as one commit titled",
610};
611
612const JA: Words = Words {
613 html_lang: "ja",
614 checks: "チェック",
615 nothing_failing: "失敗しているものはありません。",
616 files_changed: "ファイル変更",
617 commits: "squash されるコミット",
618 no_commits: "ブランチからコミット件名を読めませんでした。",
619 comments: "レビューコメント",
620 no_comments: "この時点で未対応のものはありません。",
621 diff: "差分",
622 truncated: "省略",
623 lands_as: "これらは次の件名の1コミットとして入ります:",
624};
625
626impl Words {
627 fn lands_as_tail(&self) -> &'static str {
631 if self.html_lang == "ja" {
632 "。この件名も承認の対象です。"
633 } else {
634 ", which you are approving too."
635 }
636 }
637
638 fn approval_summary(&self, number: u64, subject: &str) -> String {
640 if self.html_lang == "ja" {
641 format!("プルリクエスト #{number} をマージ: {subject}")
642 } else {
643 format!("merge pull request #{number}: {subject}")
644 }
645 }
646
647 fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
649 if self.html_lang == "ja" {
650 format!(
651 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
652 できる状態です。差分の要約・パッチ・squash されるコミットは\
653 下のパネルにあります。"
654 )
655 } else {
656 format!(
657 "{url} is green and ready to squash into `{base}` as `{subject}`. \
658 The panel holds the diffstat, the patch and the commits being squashed."
659 )
660 }
661 }
662
663 fn truncated_note(
665 &self,
666 omitted: usize,
667 total: usize,
668 shown: usize,
669 where_: &str,
670 base: &str,
671 head: &str,
672 ) -> String {
673 if self.html_lang == "ja" {
674 format!(
675 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
676 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
677 プルリクエストにあります。"
678 )
679 } else {
680 format!(
681 "{omitted} of {total} diff lines omitted after the first {shown}. \
682 The whole patch is in <code>{where_}</code> \
683 (<code>git diff {base}...{head}</code>) and on the pull request."
684 )
685 }
686 }
687}
688
689fn words(language: &str) -> &'static Words {
692 let l = language.trim();
693 if l.eq_ignore_ascii_case("ja")
694 || l.eq_ignore_ascii_case("jp")
695 || l.eq_ignore_ascii_case("japanese")
696 || l.eq_ignore_ascii_case("日本語")
697 {
698 &JA
699 } else {
700 &EN
701 }
702}
703
704pub fn approval_panel(
716 state: &RunState,
717 pr: &PrState,
718 diffstat: &str,
719 diff: &str,
720 commits: &[String],
721 subject: &str,
722) -> String {
723 let rows = parse_numstat(diffstat);
724 let w = words(&state.config.graph.language);
725 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
726
727 let _ = writeln!(
728 h,
729 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
730 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
731 w.html_lang
732 );
733 let _ = writeln!(
734 h,
735 "<title>merge #{} — {}</title>\n</head>",
736 pr.number,
737 esc(subject)
738 );
739 h.push_str(
740 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
741 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
742 word-break:break-word\">\n",
743 );
744
745 let _ = writeln!(
747 h,
748 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
749 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
750 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
751 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
752 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
753 pr.number,
754 esc(&state.base_branch),
755 esc(subject),
756 esc(&state.id),
757 esc(&pr.url),
758 esc(&pr.url),
759 );
760
761 let _ = writeln!(
762 h,
763 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
764 w.checks,
765 esc(pr.checks.as_str())
766 );
767 if pr.failing.is_empty() {
768 let _ = writeln!(
769 h,
770 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
771 w.nothing_failing
772 );
773 } else {
774 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
775 for f in &pr.failing {
776 let _ = writeln!(h, "<li>{}</li>", esc(f));
777 }
778 h.push_str("</ul>\n");
779 }
780
781 let _ = writeln!(
784 h,
785 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
786 rows.len(),
787 w.files_changed
788 );
789 h.push_str(
790 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
791 <thead><tr>\
792 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
793 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
794 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
795 </th></tr></thead>\n<tbody>\n",
796 );
797 let mut total_added = 0u64;
798 let mut total_removed = 0u64;
799 for r in &rows {
800 total_added += r.added.unwrap_or(0);
801 total_removed += r.removed.unwrap_or(0);
802 let cell = |n: Option<u64>| match n {
803 Some(n) => n.to_string(),
804 None => "bin".to_owned(),
805 };
806 let _ = writeln!(
807 h,
808 "<tr>\
809 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
810 font-family:ui-monospace,monospace\">{}</td>\
811 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
812 color:#0a3622\">{}</td>\
813 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
814 color:#5c1a17\">{}</td></tr>",
815 esc(&r.path),
816 cell(r.added),
817 cell(r.removed),
818 );
819 }
820 let _ = writeln!(
821 h,
822 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
823 <td style=\"padding:4px 2px\">total</td>\
824 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
825 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
826 </tr></tfoot>\n</table>"
827 );
828
829 let _ = writeln!(
831 h,
832 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
833 w.commits
834 );
835 if commits.is_empty() {
836 h.push_str(&format!(
837 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
838 w.no_commits
839 ));
840 } else {
841 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
842 for c in commits {
843 let _ = writeln!(h, "<li>{}</li>", esc(c));
844 }
845 h.push_str("</ol>\n");
846 }
847 let _ = writeln!(
848 h,
849 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
850 w.lands_as,
851 esc(subject),
852 w.lands_as_tail()
853 );
854
855 let _ = writeln!(
857 h,
858 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
859 w.comments
860 );
861 if pr.review_comments.is_empty() {
862 h.push_str(&format!(
863 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
864 w.no_comments
865 ));
866 } else {
867 for c in &pr.review_comments {
868 let anchor = match (&c.path, c.line) {
869 (Some(p), Some(l)) => format!("{p}:{l}"),
870 (Some(p), None) => p.clone(),
871 _ => "pull request thread".to_owned(),
872 };
873 let _ = writeln!(
874 h,
875 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
876 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
877 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
878 esc(&c.author),
879 esc(&anchor),
880 esc(&tail(&c.body, 800)),
881 );
882 }
883 }
884
885 let total = diff.lines().count();
887 let shown = total.min(DIFF_MAX_LINES);
888 let _ = writeln!(
889 h,
890 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
891 w.diff
892 );
893 h.push_str(
894 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
895 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
896 );
897 for line in diff.lines().take(shown) {
898 let (gutter, style, body) = diff_row(line);
899 let _ = writeln!(
900 h,
901 "<div style=\"display:flex;{style}\">\
902 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
903 border-right:1px solid #d0d7de\">{gutter}</span>\
904 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
905 esc(body),
906 );
907 }
908 h.push_str("</div>\n");
909 if total > shown {
910 let omitted = total - shown;
911 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
912 let where_ = state.winner().map_or_else(
913 || state.repo.display().to_string(),
914 |w| w.worktree.display().to_string(),
915 );
916 let _ = writeln!(
917 h,
918 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
919 font-size:13px\">{}: {}</p>",
920 w.truncated,
921 w.truncated_note(
922 omitted,
923 total,
924 shown,
925 &esc(&where_),
926 &esc(&state.base_branch),
927 &esc(head),
928 ),
929 );
930 }
931
932 h.push_str("</body>\n</html>\n");
933 h
934}
935
936async fn request_approval(state: &mut RunState, pr: &PrState, subject: &str) -> Result<Approval> {
942 let (worktree, head) = match state.winner() {
943 Some(w) => (w.worktree.clone(), w.branch.clone()),
944 None => (state.repo.clone(), "HEAD".to_owned()),
945 };
946 let base = state.base_branch.clone();
947 let range = format!("{base}...{head}");
948 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
952 .await
953 .map(|o| o.stdout)
954 .unwrap_or_default();
955 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
956 let commits: Vec<String> = git::git_raw(
957 &worktree,
958 &[
959 "log",
960 "--reverse",
961 "--format=%s",
962 &format!("{base}..{head}"),
963 ],
964 )
965 .await
966 .map(|o| o.stdout)
967 .unwrap_or_default()
968 .lines()
969 .filter(|l| !l.trim().is_empty())
970 .map(str::to_owned)
971 .collect();
972
973 let w = words(&state.config.graph.language);
974 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
975 let store = ask::Questions::open();
976 let mut q = ask::Question::new(
977 state.id.clone(),
978 APPROVAL_NODE.to_owned(),
979 "land".to_owned(),
980 w.approval_summary(pr.number, subject),
981 w.approval_detail(&pr.url, &base, subject),
982 vec![APPROVE.to_owned(), HOLD.to_owned()],
983 );
984 store
985 .put_panel(&mut q, &html, &[])
986 .context("write the merge approval panel")?;
987 state.event("land", format!("asking for merge approval ({})", q.short()));
988 state.save()?;
989
990 let timeout = Duration::from_secs(state.config.graph.answer_timeout);
991 let said = ask::ask_and_wait(&mut q, &store, &state.config.notify, timeout).await?;
992 Ok(approval(said.as_deref()))
993}
994
995pub fn parse_pr(json: &str) -> Result<PrState> {
998 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
999 let state = match raw.state.to_ascii_uppercase().as_str() {
1000 "OPEN" => PrLifecycle::Open,
1001 "MERGED" => PrLifecycle::Merged,
1002 "CLOSED" => PrLifecycle::Closed,
1003 other => bail!("unknown pull request state `{other}`"),
1004 };
1005
1006 let mut failing = Vec::new();
1007 let mut pending = false;
1008 let mut unknown = false;
1009 for check in &raw.status_check_rollup {
1010 match check.verdict() {
1011 Verdict::Pass => {}
1012 Verdict::Pending => pending = true,
1013 Verdict::Fail => failing.push(check.label()),
1014 Verdict::Unknown => unknown = true,
1015 }
1016 }
1017 let checks = if raw.status_check_rollup.is_empty() {
1018 Checks::Unknown
1019 } else if pending {
1020 Checks::Pending
1021 } else if !failing.is_empty() {
1022 Checks::Red
1023 } else if unknown {
1024 Checks::Unknown
1025 } else {
1026 Checks::Green
1027 };
1028
1029 let mut review_comments = Vec::new();
1030 for r in raw.reviews {
1031 push_if_outstanding(
1032 &mut review_comments,
1033 ReviewComment {
1034 author: r.author.login,
1035 path: None,
1036 line: None,
1037 body: r.body,
1038 },
1039 );
1040 }
1041 for c in raw.comments {
1042 push_if_outstanding(
1043 &mut review_comments,
1044 ReviewComment {
1045 author: c.author.login,
1046 path: None,
1047 line: None,
1048 body: c.body,
1049 },
1050 );
1051 }
1052
1053 Ok(PrState {
1054 url: raw.url,
1055 number: raw.number,
1056 state,
1057 checks,
1058 failing,
1059 review_comments,
1060 blocking: Blocking::of(&raw.merge_state_status),
1061 })
1062}
1063
1064pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1071 let raw: Vec<GhInline> =
1072 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1073 let mut out = Vec::new();
1074 for c in raw {
1075 push_if_outstanding(
1076 &mut out,
1077 ReviewComment {
1078 author: c.user.login,
1079 path: c.path,
1080 line: c.line,
1081 body: c.body,
1082 },
1083 );
1084 }
1085 Ok(out)
1086}
1087
1088fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1094 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1095 return;
1096 }
1097 if comment.path.is_none() && is_noise(&comment.body) {
1098 return;
1099 }
1100 out.push(comment);
1101}
1102
1103pub fn is_noise(body: &str) -> bool {
1121 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1122 return true;
1123 }
1124 let mut content = false;
1125 for line in strip_blocks(body).lines() {
1126 let line = unquote(line);
1127 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1128 continue;
1129 }
1130 content = true;
1131 break;
1132 }
1133 !content
1134}
1135
1136fn strip_blocks(body: &str) -> String {
1138 let mut out = String::with_capacity(body.len());
1139 let mut rest = body;
1140 loop {
1141 let open = ["<!--", "<details>"]
1142 .iter()
1143 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1144 .min_by_key(|(i, _)| *i);
1145 let Some((at, tag)) = open else {
1146 out.push_str(rest);
1147 return out;
1148 };
1149 out.push_str(&rest[..at]);
1150 let after = &rest[at + tag.len()..];
1151 let close = if tag == "<!--" { "-->" } else { "</details>" };
1152 match after.find(close) {
1153 Some(end) => rest = &after[end + close.len()..],
1154 None => return out,
1156 }
1157 }
1158}
1159
1160fn unquote(line: &str) -> &str {
1162 let mut s = line.trim();
1163 while let Some(rest) = s.strip_prefix('>') {
1164 s = rest.trim_start();
1165 }
1166 s.trim()
1167}
1168
1169fn is_checklist(line: &str) -> bool {
1171 let rest = line
1172 .strip_prefix("- ")
1173 .or_else(|| line.strip_prefix("* "))
1174 .unwrap_or("");
1175 let rest = rest.trim_start();
1176 matches!(
1177 rest.get(..3),
1178 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1179 )
1180}
1181
1182fn is_decoration(line: &str) -> bool {
1184 line.starts_with('#')
1185 || line.starts_with("[!")
1186 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1187}
1188
1189fn is_banner(line: &str) -> bool {
1196 let plain = drop_spans(line, "**", "**");
1197 let plain = if plain.contains("](") {
1198 drop_spans(&plain, "[", ")")
1199 } else {
1200 plain
1201 };
1202 !plain.chars().any(char::is_alphanumeric)
1203}
1204
1205fn drop_spans(s: &str, open: &str, close: &str) -> String {
1209 let mut out = String::with_capacity(s.len());
1210 let mut rest = s;
1211 while let Some(at) = rest.find(open) {
1212 out.push_str(&rest[..at]);
1213 let after = &rest[at + open.len()..];
1214 match after.find(close) {
1215 Some(end) => rest = &after[end + close.len()..],
1216 None => return out,
1217 }
1218 }
1219 out.push_str(rest);
1220 out
1221}
1222
1223pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1230 let repo = state.repo.clone();
1231 let budget = state.config.graph.land_rounds;
1232 let mut round = 0usize;
1233 let mut rebases = 0usize;
1236 let mut waited = Duration::ZERO;
1237 let mut shown: BTreeSet<String> = BTreeSet::new();
1242
1243 state.event("land", format!("watching {pr_url}"));
1244 state.save()?;
1245
1246 loop {
1247 let seen = observe(&repo, pr_url).await?;
1248 let mut pr = seen.pr;
1249 pr.review_comments.retain(|c| !shown.contains(&c.body));
1250 state.pr = Some(crate::run::PrRecord {
1251 url: pr.url.clone(),
1252 number: pr.number,
1253 state: pr.state.as_str().to_owned(),
1254 checks: pr.checks.as_str().to_owned(),
1255 round,
1256 rounds: budget,
1257 });
1258 state.save()?;
1259
1260 match decide(&pr, round, budget, waited) {
1261 Step::Wait => {
1262 if waited >= WAIT_CEILING {
1263 let why = format!(
1264 "checks were still running after {} minutes",
1265 WAIT_CEILING.as_secs() / 60
1266 );
1267 stop(state, &repo, &pr, &why).await?;
1268 return Ok(pr);
1269 }
1270 waited += POLL;
1271 tokio::time::sleep(POLL).await;
1272 }
1273 Step::Done { merged } => {
1274 state.status = if merged {
1275 RunStatus::Merged
1276 } else {
1277 RunStatus::Ready
1278 };
1279 let detail = if merged {
1280 format!("{} was merged", pr.url)
1281 } else {
1282 format!("{} was closed without merging", pr.url)
1283 };
1284 state.merge = Some(MergeOutcome {
1285 mode: MergeMode::Pr,
1286 ok: merged,
1287 detail: detail.clone(),
1288 });
1289 state.event("land", detail);
1290 state.save()?;
1291 return Ok(pr);
1292 }
1293 Step::Merge => {
1294 let subject = merge_subject(&seen.title, &state.instruction);
1295 if state.config.graph.land_approval
1298 && request_approval(state, &pr, &subject).await? == Approval::Hold
1299 {
1300 stop(
1301 state,
1302 &repo,
1303 &pr,
1304 "the owner did not approve the merge (held or unanswered)",
1305 )
1306 .await?;
1307 return Ok(pr);
1308 }
1309 let argv = merge_argv(pr.number, &subject);
1310 let out = gh(&repo, &argv).await?;
1311 if out.0 {
1312 state.status = RunStatus::Merged;
1313 state.merge = Some(MergeOutcome {
1314 mode: MergeMode::Pr,
1315 ok: true,
1316 detail: format!("gh {}", argv.join(" ")),
1317 });
1318 state.event("land", format!("merged {} as `{subject}`", pr.url));
1319 state.save()?;
1320 pr.state = PrLifecycle::Merged;
1321 return Ok(pr);
1322 }
1323 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
1324 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
1325 state.status = RunStatus::Merged;
1326 state.merge = Some(outcome);
1327 state.event("land", format!("merged {} as `{subject}`", pr.url));
1328 state.save()?;
1329 pr.state = PrLifecycle::Merged;
1330 return Ok(pr);
1331 }
1332 stop(
1333 state,
1334 &repo,
1335 &pr,
1336 &format!("`gh pr merge` failed: {}", out.1),
1337 )
1338 .await?;
1339 return Ok(pr);
1340 }
1341 Step::Rebase => {
1342 if rebases >= budget {
1348 let why = format!(
1349 "the base moved under this branch {budget} time(s) and it still does \
1350 not merge; rebasing again would only race it"
1351 );
1352 stop(state, &repo, &pr, &why).await?;
1353 return Ok(pr);
1354 }
1355 rebases += 1;
1356 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
1357 stop(
1358 state,
1359 &repo,
1360 &pr,
1361 "the pull request conflicts and this run has no winning branch to rebase",
1362 )
1363 .await?;
1364 return Ok(pr);
1365 };
1366 let base = state.base_branch.clone();
1367 state.event(
1368 "land",
1369 format!("{} no longer merges; rebasing onto {base}", pr.url),
1370 );
1371 state.save()?;
1372
1373 git::fetch(&repo, "origin", &base).await.ok();
1377 let scratch = state.dir().join("rebase");
1378 let onto = format!("origin/{base}");
1379 match git::rebase_branch_in_temp(&repo, &scratch, &branch, &onto).await {
1380 Ok(None) => {
1381 let pushed = git::push_rewritten(&repo, "origin", &branch).await?;
1382 if !pushed.ok() {
1383 let why = format!(
1384 "rebased {branch} but could not push it: {}",
1385 pushed.stderr.trim()
1386 );
1387 stop(state, &repo, &pr, &why).await?;
1388 return Ok(pr);
1389 }
1390 state.event("land", format!("rebased {branch} onto {base}"));
1391 state.save()?;
1392 waited = Duration::ZERO;
1395 tokio::time::sleep(POLL).await;
1396 }
1397 Ok(Some(conflict)) => {
1399 let why = format!(
1400 "{} conflicts with {base} and the rebase did not apply: {}",
1401 pr.url,
1402 conflict.chars().take(600).collect::<String>()
1403 );
1404 stop(state, &repo, &pr, &why).await?;
1405 return Ok(pr);
1406 }
1407 Err(e) => {
1408 let why = format!("could not rebase {branch} onto {base}: {e:#}");
1409 stop(state, &repo, &pr, &why).await?;
1410 return Ok(pr);
1411 }
1412 }
1413 }
1414 Step::GiveUp { reason } => {
1415 stop(state, &repo, &pr, &reason).await?;
1416 return Ok(pr);
1417 }
1418 Step::Fix { reason } => {
1419 round += 1;
1420 waited = Duration::ZERO;
1421 for c in &pr.review_comments {
1422 shown.insert(c.body.clone());
1423 }
1424 state.event("land", format!("round {round}: {reason}"));
1425 state.save()?;
1426
1427 let logs = failing_logs(&repo, &seen.failing_urls).await;
1428 let was_red = pr.checks == Checks::Red;
1429 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
1430 Fixed::Committed => {}
1431 Fixed::Declined if was_red => {
1432 let why = format!(
1433 "the fixer produced no commit while {} check(s) were failing; \
1434 stopping instead of looping on an unchanged tree",
1435 pr.failing.len()
1436 );
1437 stop(state, &repo, &pr, &why).await?;
1438 return Ok(pr);
1439 }
1440 Fixed::Declined => state.event(
1445 "land",
1446 format!("round {round}: fixer declined the comments, nothing committed"),
1447 ),
1448 Fixed::Failed(why) => {
1449 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
1450 return Ok(pr);
1451 }
1452 }
1453 state.save()?;
1454 }
1455 }
1456 }
1457}
1458
1459struct Seen {
1463 pr: PrState,
1464 title: String,
1465 failing_urls: Vec<(String, String)>,
1466}
1467
1468async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
1471 let view = gh(
1472 repo,
1473 &[
1474 "pr".to_owned(),
1475 "view".to_owned(),
1476 pr_url.to_owned(),
1477 "--json".to_owned(),
1478 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
1479 ],
1480 )
1481 .await?;
1482 if !view.0 {
1483 bail!("gh pr view {pr_url}: {}", view.1);
1484 }
1485 let mut pr = parse_pr(&view.1)?;
1486 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
1487
1488 let inline = gh(
1489 repo,
1490 &[
1491 "api".to_owned(),
1492 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
1493 ],
1494 )
1495 .await?;
1496 if inline.0 {
1497 match parse_inline_comments(&inline.1) {
1498 Ok(mut comments) => pr.review_comments.append(&mut comments),
1499 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
1502 }
1503 } else {
1504 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
1505 }
1506
1507 let failing_urls = raw
1508 .status_check_rollup
1509 .iter()
1510 .filter(|c| c.verdict() == Verdict::Fail)
1511 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
1512 .collect();
1513
1514 Ok(Seen {
1515 pr,
1516 title: raw.title,
1517 failing_urls,
1518 })
1519}
1520
1521enum Fixed {
1523 Committed,
1525 Declined,
1527 Failed(String),
1529}
1530
1531async fn fix_round(
1537 state: &mut RunState,
1538 pr: &PrState,
1539 round: usize,
1540 budget: usize,
1541 reason: &str,
1542 logs: &str,
1543) -> Result<Fixed> {
1544 let winner = state
1545 .winner()
1546 .cloned()
1547 .context("landing needs a winning candidate; none is recorded on this run")?;
1548 let roles = state
1549 .config
1550 .resolve_roles()
1551 .context("resolve the roster for the fix round")?;
1552 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
1556 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1557 _ => (
1558 state
1559 .config
1560 .agent(&winner.agent)
1561 .cloned()
1562 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
1563 format!("impl-{}", winner.label),
1564 ),
1565 };
1566
1567 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
1568 let mut seat = seat_of(state, &seat_key, &spec.id);
1569 let artifacts = agent::artifacts_dir(&state.dir());
1570 let out = agent::invoke(
1571 &spec,
1572 &mut seat,
1573 &Invocation {
1574 cwd: &winner.worktree,
1575 prompt: &prompt,
1576 timeout: Duration::from_secs(state.config.graph.timeout_fix),
1577 allow_write: true,
1578 sessions: state.config.graph.sessions,
1579 artifacts: &artifacts,
1580 stem: &format!("land-{round}"),
1581 run: &state.id,
1582 node: "land",
1583 },
1584 )
1585 .await;
1586 state.seats.insert(seat.key.clone(), seat);
1587
1588 match out {
1589 Ok(o) if o.quota_exhausted() => {
1590 return Ok(Fixed::Failed(
1591 "rate limited (quota); the fixer could not run".to_owned(),
1592 ));
1593 }
1594 Ok(o) if !o.usable() => {
1595 return Ok(Fixed::Failed(format!(
1596 "the fixer produced nothing usable (exit {:?}, timed out: {})",
1597 o.exit_code, o.timed_out
1598 )));
1599 }
1600 Ok(_) => {}
1601 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
1602 }
1603
1604 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1605 git::commit_all(
1608 &winner.worktree,
1609 &format!("magi: land round {round} fixes (uncommitted work)"),
1610 )
1611 .await
1612 .ok();
1613 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
1614 if after == before {
1615 return Ok(Fixed::Declined);
1616 }
1617
1618 let remote = state.config.merge.remote.clone();
1619 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
1620 if !push.ok() {
1621 return Ok(Fixed::Failed(format!(
1622 "pushing {} to {remote} failed: {}",
1623 winner.branch, push.stderr
1624 )));
1625 }
1626 state.event(
1627 "land",
1628 format!("round {round}: pushed a fix to {}", winner.branch),
1629 );
1630 Ok(Fixed::Committed)
1631}
1632
1633fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
1635 if let Some(existing) = state.seats.get(key)
1636 && existing.agent == agent
1637 {
1638 return existing.clone();
1639 }
1640 let fresh = SeatState::new(key, agent, state.seed);
1641 state.seats.insert(key.to_owned(), fresh.clone());
1642 fresh
1643}
1644
1645fn fix_prompt(
1647 state: &RunState,
1648 pr: &PrState,
1649 round: usize,
1650 budget: usize,
1651 reason: &str,
1652 logs: &str,
1653) -> String {
1654 let mut s = format!(
1655 "Your patch is open as a pull request and it is not landing. Land round \
1656 {round} of {budget}.\n\n\
1657 Pull request: {}\n\n\
1658 What is holding it: {reason}\n\n\
1659 # The task\n\n{}\n",
1660 pr.url, state.instruction
1661 );
1662
1663 if pr.failing.is_empty() {
1664 s.push_str("\n# Failing checks\n\n(none)\n");
1665 } else {
1666 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
1667 if logs.trim().is_empty() {
1668 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
1669 } else {
1670 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
1671 }
1672 }
1673
1674 if pr.review_comments.is_empty() {
1675 s.push_str("\n# Review comments\n\n(none)\n");
1676 } else {
1677 s.push_str("\n# Review comments\n");
1678 for c in &pr.review_comments {
1679 let where_ = match (&c.path, c.line) {
1680 (Some(p), Some(l)) => format!(" ({p}:{l})"),
1681 (Some(p), None) => format!(" ({p})"),
1682 _ => String::new(),
1683 };
1684 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
1685 }
1686 }
1687
1688 s.push_str(
1689 "\n# Rules\n\n\
1690 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
1691 failing test; do not silence a lint with an allow attribute; do not \
1692 stretch a timeout to hide a race. If the check is right, the code is \
1693 wrong.\n\
1694 2. Change nothing the checks and the comments did not raise. A \
1695 drive-by refactor turns a one-line fix into a pull request that \
1696 needs reviewing again.\n\
1697 3. If a comment is wrong, say so with a checkable argument and change \
1698 nothing for it. A declined comment with a reason is a correct \
1699 outcome; a change made to appease a reviewer is not.\n\
1700 4. Commit in this worktree. magi pushes to the pull request's branch \
1701 for you; do not push, merge, or close anything yourself.\n\
1702 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
1703 # Output\n\n\
1704 Say what you changed and why, and what you declined and why.",
1705 );
1706
1707 let language = &state.config.graph.language;
1708 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
1709 let _ = write!(s, "\n\nWrite all prose in {language}.");
1710 }
1711 if let Some(overlay) = state.config.prompts.overlay("fix") {
1712 let _ = write!(s, "\n\n{overlay}");
1713 }
1714 s
1715}
1716
1717async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
1720 let mut out = String::new();
1721 for (name, url) in failing.iter().take(MAX_LOGS) {
1722 let args = match (job_of(url), run_of(url)) {
1723 (Some(job), _) => vec![
1724 "run".to_owned(),
1725 "view".to_owned(),
1726 "--log-failed".to_owned(),
1727 "--job".to_owned(),
1728 job,
1729 ],
1730 (None, Some(run)) => vec![
1731 "run".to_owned(),
1732 "view".to_owned(),
1733 run,
1734 "--log-failed".to_owned(),
1735 ],
1736 (None, None) => continue,
1738 };
1739 let (ok, body) = match gh(repo, &args).await {
1740 Ok(v) => v,
1741 Err(e) => (false, format!("{e:#}")),
1742 };
1743 if !ok && body.trim().is_empty() {
1744 continue;
1745 }
1746 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
1747 }
1748 out
1749}
1750
1751fn job_of(details_url: &str) -> Option<String> {
1754 let after = details_url.split("/job/").nth(1)?;
1755 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1756 (!id.is_empty()).then_some(id)
1757}
1758
1759fn run_of(details_url: &str) -> Option<String> {
1761 let after = details_url.split("/actions/runs/").nth(1)?;
1762 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1763 (!id.is_empty()).then_some(id)
1764}
1765
1766async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
1771 let body = format!(
1772 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
1773 The branch is untouched and the run is `{}`. Nothing was merged.",
1774 state.id
1775 );
1776 let posted = gh(
1777 repo,
1778 &[
1779 "pr".to_owned(),
1780 "comment".to_owned(),
1781 pr.number.to_string(),
1782 "--body".to_owned(),
1783 body,
1784 ],
1785 )
1786 .await;
1787 match posted {
1788 Ok((true, _)) => {}
1789 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
1790 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
1791 }
1792 state.status = RunStatus::Blocked;
1793 state.merge = Some(MergeOutcome {
1794 mode: MergeMode::Pr,
1795 ok: false,
1796 detail: why.to_owned(),
1797 });
1798 state.event("land", format!("stopped: {why}"));
1799 state.save()?;
1800 Ok(())
1801}
1802
1803async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
1808 let out = tokio::process::Command::new("gh")
1809 .args(args)
1810 .current_dir(cwd)
1811 .stdin(std::process::Stdio::null())
1812 .output()
1813 .await
1814 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
1815 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
1816 let err = String::from_utf8_lossy(&out.stderr);
1817 if body.trim().is_empty() {
1818 body = err.into_owned();
1819 } else if !err.trim().is_empty() {
1820 body.push_str(&err);
1821 }
1822 Ok((out.status.success(), body.trim().to_owned()))
1823}
1824
1825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827enum Verdict {
1828 Pass,
1829 Fail,
1830 Pending,
1831 Unknown,
1832}
1833
1834#[derive(Debug, Deserialize)]
1835#[serde(rename_all = "camelCase")]
1836struct GhPr {
1837 #[serde(default)]
1838 url: String,
1839 #[serde(default)]
1840 number: u64,
1841 #[serde(default)]
1842 state: String,
1843 #[serde(default)]
1844 title: String,
1845 #[serde(default)]
1846 status_check_rollup: Vec<GhCheck>,
1847 #[serde(default)]
1854 merge_state_status: String,
1855 #[serde(default)]
1856 reviews: Vec<GhReview>,
1857 #[serde(default)]
1858 comments: Vec<GhComment>,
1859}
1860
1861#[derive(Debug, Deserialize)]
1866#[serde(rename_all = "camelCase")]
1867struct GhCheck {
1868 #[serde(default)]
1869 name: Option<String>,
1870 #[serde(default)]
1871 context: Option<String>,
1872 #[serde(default)]
1873 status: Option<String>,
1874 #[serde(default)]
1875 conclusion: Option<String>,
1876 #[serde(default)]
1877 state: Option<String>,
1878 #[serde(default)]
1879 details_url: Option<String>,
1880 #[serde(default)]
1881 target_url: Option<String>,
1882}
1883
1884impl GhCheck {
1885 fn label(&self) -> String {
1887 self.name
1888 .clone()
1889 .or_else(|| self.context.clone())
1890 .unwrap_or_else(|| "(unnamed check)".to_owned())
1891 }
1892
1893 fn url(&self) -> Option<&str> {
1895 self.details_url
1896 .as_deref()
1897 .or(self.target_url.as_deref())
1898 .filter(|u| !u.is_empty())
1899 }
1900
1901 fn verdict(&self) -> Verdict {
1909 if let Some(status) = self.status.as_deref() {
1910 if !status.eq_ignore_ascii_case("COMPLETED") {
1911 return Verdict::Pending;
1912 }
1913 }
1914 let outcome = self
1915 .conclusion
1916 .as_deref()
1917 .or(self.state.as_deref())
1918 .unwrap_or("");
1919 match outcome.to_ascii_uppercase().as_str() {
1920 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
1921 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
1922 | "ACTION_REQUIRED" => Verdict::Fail,
1923 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
1924 Verdict::Pending
1925 }
1926 _ => Verdict::Unknown,
1927 }
1928 }
1929}
1930
1931#[derive(Debug, Deserialize)]
1932struct GhAuthor {
1933 #[serde(default)]
1934 login: String,
1935}
1936
1937#[derive(Debug, Deserialize)]
1938struct GhReview {
1939 #[serde(default)]
1940 author: GhAuthor,
1941 #[serde(default)]
1942 body: String,
1943}
1944
1945#[derive(Debug, Deserialize)]
1946struct GhComment {
1947 #[serde(default)]
1948 author: GhAuthor,
1949 #[serde(default)]
1950 body: String,
1951}
1952
1953#[derive(Debug, Deserialize)]
1954struct GhUser {
1955 #[serde(default)]
1956 login: String,
1957}
1958
1959#[derive(Debug, Deserialize)]
1960struct GhInline {
1961 #[serde(default)]
1962 user: GhUser,
1963 #[serde(default)]
1964 path: Option<String>,
1965 #[serde(default)]
1966 line: Option<u64>,
1967 #[serde(default)]
1968 body: String,
1969}
1970
1971impl Default for GhAuthor {
1972 fn default() -> Self {
1973 Self {
1974 login: "(unknown)".to_owned(),
1975 }
1976 }
1977}
1978
1979impl Default for GhUser {
1980 fn default() -> Self {
1981 Self {
1982 login: "(unknown)".to_owned(),
1983 }
1984 }
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990
1991 const GREEN_OPEN: &str = r####"{
1993 "url": "https://github.com/yukimemi/magi/pull/10",
1994 "number": 10,
1995 "state": "OPEN",
1996 "mergeStateStatus": "CLEAN",
1997 "statusCheckRollup": [
1998 {
1999 "__typename": "CheckRun",
2000 "conclusion": "SKIPPED",
2001 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2002 "name": "review",
2003 "status": "COMPLETED",
2004 "workflowName": "claude-review"
2005 },
2006 {
2007 "__typename": "CheckRun",
2008 "conclusion": "SUCCESS",
2009 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2010 "name": "check (ubuntu-latest)",
2011 "status": "COMPLETED",
2012 "workflowName": "CI"
2013 },
2014 {
2015 "__typename": "CheckRun",
2016 "conclusion": "SUCCESS",
2017 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2018 "name": "rustfmt",
2019 "status": "COMPLETED",
2020 "workflowName": "CI"
2021 },
2022 {
2023 "__typename": "StatusContext",
2024 "context": "CodeRabbit",
2025 "state": "SUCCESS",
2026 "targetUrl": ""
2027 }
2028 ],
2029 "reviews": [],
2030 "comments": [
2031 {
2032 "author": {
2033 "login": "coderabbitai"
2034 },
2035 "authorAssociation": "NONE",
2036 "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"
2037 }
2038 ]
2039}"####;
2040
2041 const RED_OPEN: &str = r####"{
2043 "url": "https://github.com/yukimemi/magi/pull/9",
2044 "number": 9,
2045 "state": "OPEN",
2046 "mergeStateStatus": "UNSTABLE",
2047 "statusCheckRollup": [
2048 {
2049 "__typename": "CheckRun",
2050 "conclusion": "SUCCESS",
2051 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2052 "name": "check (ubuntu-latest)",
2053 "status": "COMPLETED",
2054 "workflowName": "CI"
2055 },
2056 {
2057 "__typename": "CheckRun",
2058 "conclusion": "SUCCESS",
2059 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2060 "name": "rustfmt",
2061 "status": "COMPLETED",
2062 "workflowName": "CI"
2063 },
2064 {
2065 "__typename": "CheckRun",
2066 "conclusion": "FAILURE",
2067 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2068 "name": "editorconfig",
2069 "status": "COMPLETED",
2070 "workflowName": "CI"
2071 },
2072 {
2073 "__typename": "StatusContext",
2074 "context": "CodeRabbit",
2075 "state": "SUCCESS",
2076 "targetUrl": ""
2077 }
2078 ],
2079 "reviews": [],
2080 "comments": [
2081 {
2082 "author": {
2083 "login": "coderabbitai"
2084 },
2085 "authorAssociation": "NONE",
2086 "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"
2087 }
2088 ]
2089}"####;
2090
2091 const PENDING_OPEN: &str = r####"{
2093 "url": "https://github.com/yukimemi/magi/pull/9",
2094 "number": 9,
2095 "state": "OPEN",
2096 "statusCheckRollup": [
2097 {
2098 "__typename": "CheckRun",
2099 "conclusion": "SUCCESS",
2100 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2101 "name": "check (ubuntu-latest)",
2102 "status": "COMPLETED",
2103 "workflowName": "CI"
2104 },
2105 {
2106 "__typename": "CheckRun",
2107 "conclusion": "SUCCESS",
2108 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2109 "name": "rustfmt",
2110 "status": "COMPLETED",
2111 "workflowName": "CI"
2112 },
2113 {
2114 "__typename": "CheckRun",
2115 "conclusion": null,
2116 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2117 "name": "editorconfig",
2118 "status": "IN_PROGRESS",
2119 "workflowName": "CI"
2120 },
2121 {
2122 "__typename": "StatusContext",
2123 "context": "CodeRabbit",
2124 "state": "SUCCESS",
2125 "targetUrl": ""
2126 }
2127 ],
2128 "reviews": [],
2129 "comments": []
2130}"####;
2131
2132 const MERGED: &str = r####"{
2134 "url": "https://github.com/yukimemi/magi/pull/16",
2135 "number": 16,
2136 "state": "MERGED",
2137 "statusCheckRollup": [
2138 {
2139 "__typename": "CheckRun",
2140 "conclusion": "SUCCESS",
2141 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2142 "name": "check (ubuntu-latest)",
2143 "status": "COMPLETED",
2144 "workflowName": "CI"
2145 },
2146 {
2147 "__typename": "CheckRun",
2148 "conclusion": "SUCCESS",
2149 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2150 "name": "review",
2151 "status": "COMPLETED",
2152 "workflowName": "claude-review"
2153 }
2154 ],
2155 "reviews": [],
2156 "comments": []
2157}"####;
2158
2159 const REVIEWED_OPEN: &str = r####"{
2161 "url": "https://github.com/yukimemi/magi/pull/12",
2162 "number": 12,
2163 "state": "OPEN",
2164 "statusCheckRollup": [
2165 {
2166 "__typename": "CheckRun",
2167 "conclusion": "SUCCESS",
2168 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2169 "name": "check (ubuntu-latest)",
2170 "status": "COMPLETED",
2171 "workflowName": "CI"
2172 },
2173 {
2174 "__typename": "CheckRun",
2175 "conclusion": "SUCCESS",
2176 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2177 "name": "review",
2178 "status": "COMPLETED",
2179 "workflowName": "claude-review"
2180 }
2181 ],
2182 "reviews": [
2183 {
2184 "author": {
2185 "login": "claude"
2186 },
2187 "state": "COMMENTED",
2188 "body": ""
2189 }
2190 ],
2191 "comments": [
2192 {
2193 "author": {
2194 "login": "coderabbitai"
2195 },
2196 "authorAssociation": "NONE",
2197 "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"
2198 },
2199 {
2200 "author": {
2201 "login": "claude"
2202 },
2203 "authorAssociation": "NONE",
2204 "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"
2205 }
2206 ]
2207}"####;
2208
2209 const INLINE: &str = r####"[
2211 {
2212 "user": {
2213 "login": "claude[bot]"
2214 },
2215 "path": "src/graph.rs",
2216 "line": 231,
2217 "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"
2218 }
2219]"####;
2220
2221 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
2223<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
2224
2225> [!IMPORTANT]
2226> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
2227>
2228> This repository does not receive automatic reviews because it has fewer than 10 stars.
2229>
2230> <details>
2231> <summary>⚙️ Run configuration</summary>
2232>
2233> **Configuration used**: defaults
2234>
2235> **Review profile**: CHILL
2236>
2237> **Plan**: Team
2238>
2239> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
2240>
2241> </details>
2242
2243<!-- end of auto-generated comment: skip review by coderabbit.ai -->
2244
2245<!-- tips_start -->
2246
2247---
2248
2249Thanks 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.
2250
2251<details>
2252<summary>❤️ Share</summary>
2253
2254- [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"####;
2255
2256 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
2258
2259---
2260### Reviewing PR #16
2261
2262- [x] Read AGENTS.md conventions
2263- [x] Review `src/daemon.rs` changes
2264- [x] Review `src/main.rs` changes (new `doctor` reporting)
2265- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
2266- [x] Check test coverage for new behavior
2267- [x] Run verification commands (blocked — see note)
2268- [x] Post findings"####;
2269
2270 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
2272
2273---
2274### Review: `magi review <branch>` — cheap-half-only graph
2275
2276Read 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.
2277
2278**Correctness**
2279
2280- 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"####;
2281
2282 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
2283 PrState {
2284 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
2285 number: 16,
2286 state: PrLifecycle::Open,
2287 checks,
2288 blocking: if matches!(checks, Checks::Red) {
2292 Blocking::Yes
2293 } else {
2294 Blocking::No
2295 },
2296 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
2297 review_comments: (0..comments)
2298 .map(|i| ReviewComment {
2299 author: "coderabbitai".to_owned(),
2300 path: Some("src/graph.rs".to_owned()),
2301 line: Some(231),
2302 body: format!("finding {i}"),
2303 })
2304 .collect(),
2305 }
2306 }
2307
2308 #[test]
2309 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
2310 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
2311 assert_eq!(state.number, 10);
2312 assert_eq!(state.state, PrLifecycle::Open);
2313 assert_eq!(state.checks, Checks::Green);
2314 assert!(state.failing.is_empty());
2315 assert!(
2316 state.review_comments.is_empty(),
2317 "the only comment is CodeRabbit's trigger notice: {:?}",
2318 state.review_comments
2319 );
2320 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
2321 }
2322
2323 #[test]
2324 fn a_failing_check_parses_as_red_and_is_named() {
2325 let state = parse_pr(RED_OPEN).expect("red fixture parses");
2326 assert_eq!(state.checks, Checks::Red);
2327 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
2328 let mut blocking = state.clone();
2335 blocking.blocking = Blocking::Yes;
2336 match decide(&blocking, 0, 4, Duration::ZERO) {
2337 Step::Fix { reason } => {
2338 assert!(reason.contains("editorconfig"), "reason: {reason}");
2339 assert!(reason.contains("failing"), "reason: {reason}");
2340 }
2341 other => panic!("expected a fix round, got {other:?}"),
2342 }
2343 }
2344
2345 #[test]
2346 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
2347 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
2348 assert_eq!(state.checks, Checks::Pending);
2349 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
2350 }
2351
2352 #[test]
2353 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
2354 let state = parse_pr(MERGED).expect("merged fixture parses");
2355 assert_eq!(state.state, PrLifecycle::Merged);
2356 assert_eq!(
2357 decide(&state, 0, 4, Duration::ZERO),
2358 Step::Done { merged: true }
2359 );
2360 }
2361
2362 #[test]
2363 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
2364 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
2365 assert_eq!(state.checks, Checks::Green);
2366 let authors: Vec<&str> = state
2367 .review_comments
2368 .iter()
2369 .map(|c| c.author.as_str())
2370 .collect();
2371 assert_eq!(
2372 authors,
2373 vec!["claude"],
2374 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
2375 );
2376 match decide(&state, 0, 4, Duration::ZERO) {
2377 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
2378 other => panic!("expected a fix round, got {other:?}"),
2379 }
2380 }
2381
2382 #[test]
2383 fn inline_review_comments_keep_their_file_and_line() {
2384 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
2385 assert_eq!(comments.len(), 1);
2386 assert_eq!(comments[0].author, "claude[bot]");
2387 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
2388 assert_eq!(comments[0].line, Some(231));
2389 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
2390 }
2391
2392 #[test]
2393 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
2394 assert!(
2395 is_noise(CODERABBIT_TRIGGER),
2396 "CodeRabbit's trigger notice declares itself not a review"
2397 );
2398 assert!(
2399 is_noise(CLAUDE_CHECKLIST),
2400 "a progress checklist asks for nothing"
2401 );
2402 assert!(
2403 !is_noise(CLAUDE_FINDING),
2404 "a review that names a bug is input, not noise"
2405 );
2406
2407 let mut clean = pr(Checks::Green, &[], 0);
2408 clean.review_comments.push(ReviewComment {
2409 author: "coderabbitai".to_owned(),
2410 path: None,
2411 line: None,
2412 body: CODERABBIT_TRIGGER.to_owned(),
2413 });
2414 clean.review_comments.retain(|c| !is_noise(&c.body));
2415 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
2416
2417 let mut found = pr(Checks::Green, &[], 0);
2418 found.review_comments.push(ReviewComment {
2419 author: "claude".to_owned(),
2420 path: None,
2421 line: None,
2422 body: CLAUDE_FINDING.to_owned(),
2423 });
2424 found.review_comments.retain(|c| !is_noise(&c.body));
2425 assert!(matches!(
2426 decide(&found, 0, 4, Duration::ZERO),
2427 Step::Fix { .. }
2428 ));
2429 }
2430
2431 #[test]
2432 fn the_policy_table_holds_for_every_combination_that_matters() {
2433 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
2434 (
2435 "pending checks are waited for, even on the last round",
2436 pr(Checks::Pending, &[], 0),
2437 4,
2438 4,
2439 Duration::ZERO,
2440 Step::Wait,
2441 ),
2442 (
2443 "red checks are fixed",
2444 pr(Checks::Red, &["editorconfig"], 0),
2445 0,
2446 4,
2447 Duration::ZERO,
2448 Step::Fix {
2449 reason: "1 check(s) failing: editorconfig".to_owned(),
2450 },
2451 ),
2452 (
2453 "green with comments is fixed, not merged",
2454 pr(Checks::Green, &[], 2),
2455 1,
2456 4,
2457 Duration::ZERO,
2458 Step::Fix {
2459 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
2460 .to_owned(),
2461 },
2462 ),
2463 (
2464 "green and clean merges",
2465 pr(Checks::Green, &[], 0),
2466 3,
2467 4,
2468 Duration::ZERO,
2469 Step::Merge,
2470 ),
2471 (
2472 "an unreadable rollup is waited on while the grace lasts",
2473 pr(Checks::Unknown, &[], 0),
2474 0,
2475 4,
2476 Duration::ZERO,
2477 Step::Wait,
2478 ),
2479 (
2480 "an unreadable rollup is never merged once the grace is spent",
2481 pr(Checks::Unknown, &[], 0),
2482 0,
2483 4,
2484 CHECKS_GRACE,
2485 Step::GiveUp {
2486 reason: "no check status is readable on the pull request after 3 minute(s); \
2487 refusing to merge on a guess"
2488 .to_owned(),
2489 },
2490 ),
2491 ];
2492 for (what, state, round, budget, waited, want) in cases {
2493 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
2494 }
2495 }
2496
2497 #[test]
2498 fn the_forge_verdict_survives_the_round_trip_from_gh() {
2499 let green = parse_pr(GREEN_OPEN).expect("parse");
2503 assert_eq!(green.blocking, Blocking::No);
2504 let red = parse_pr(RED_OPEN).expect("parse");
2505 assert_eq!(
2506 red.blocking,
2507 Blocking::No,
2508 "`UNSTABLE` is mergeable: the red check is one nobody requires"
2509 );
2510 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
2511 let quiet =
2513 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
2514 assert_eq!(quiet.blocking, Blocking::Unsaid);
2515 }
2516
2517 #[test]
2518 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
2519 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
2525 nonblocking.blocking = Blocking::No;
2526 assert_eq!(
2527 decide(&nonblocking, 0, 4, Duration::ZERO),
2528 Step::Merge,
2529 "the forge says nothing is in the way, so nothing is"
2530 );
2531
2532 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
2534 blocking.blocking = Blocking::Yes;
2535 assert!(matches!(
2536 decide(&blocking, 0, 4, Duration::ZERO),
2537 Step::Fix { .. }
2538 ));
2539
2540 let mut commented = pr(Checks::Red, &["coverage"], 1);
2543 commented.blocking = Blocking::No;
2544 assert!(matches!(
2545 decide(&commented, 0, 4, Duration::ZERO),
2546 Step::Fix { .. }
2547 ));
2548
2549 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
2551 unsaid.blocking = Blocking::Unsaid;
2552 assert!(matches!(
2553 decide(&unsaid, 0, 4, Duration::ZERO),
2554 Step::Fix { .. }
2555 ));
2556 }
2557
2558 #[test]
2559 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
2560 let mut conflicted = pr(Checks::Green, &[], 0);
2565 conflicted.blocking = Blocking::Conflict;
2566 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
2567
2568 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
2572 red.blocking = Blocking::Conflict;
2573 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
2574
2575 let mut merged = pr(Checks::Red, &[], 0);
2577 merged.blocking = Blocking::Conflict;
2578 merged.state = PrLifecycle::Merged;
2579 assert_eq!(
2580 decide(&merged, 0, 4, Duration::ZERO),
2581 Step::Done { merged: true }
2582 );
2583 }
2584
2585 #[test]
2586 fn the_forge_verdict_is_read_off_merge_state_status() {
2587 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
2590 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
2591 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
2592 }
2593 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
2594 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
2595 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
2596 for quiet in ["", "UNKNOWN"] {
2599 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
2600 assert!(Blocking::of(quiet).stops_a_merge());
2601 }
2602 }
2603
2604 #[test]
2605 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
2606 let argv = merge_argv(28, "Merge magi run ec12 (candidate B)");
2607 let jj = "could not determine current branch: failed to run git: not on any branch";
2609
2610 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
2611 .expect("the forge says merged, so it merged");
2612 assert!(landed.ok);
2613 assert!(
2614 landed.detail.contains("but the pull request is merged"),
2615 "the record must not read as a clean success: {}",
2616 landed.detail
2617 );
2618 assert!(
2619 landed.detail.contains("not on any branch"),
2620 "and it must keep what the command actually said: {}",
2621 landed.detail
2622 );
2623
2624 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
2626 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
2627 assert!(merged_after_all(&argv, jj, None).is_none());
2629 }
2630
2631 #[test]
2632 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
2633 let mut state = pr(Checks::Red, &["editorconfig"], 3);
2634 state.state = PrLifecycle::Closed;
2635 assert_eq!(
2636 decide(&state, 0, 4, Duration::ZERO),
2637 Step::Done { merged: false },
2638 "a human closing the pull request ends the loop, whatever CI says"
2639 );
2640 }
2641
2642 #[test]
2643 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
2644 let red = decide(
2645 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
2646 4,
2647 4,
2648 Duration::ZERO,
2649 );
2650 match red {
2651 Step::GiveUp { reason } => {
2652 assert!(reason.contains("editorconfig"), "reason: {reason}");
2653 assert!(reason.contains("test (macos)"), "reason: {reason}");
2654 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
2655 }
2656 other => panic!("expected a give-up, got {other:?}"),
2657 }
2658
2659 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
2660 match commented {
2661 Step::GiveUp { reason } => {
2662 assert!(reason.contains("unresolved"), "reason: {reason}");
2663 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
2664 }
2665 other => panic!("expected a give-up, got {other:?}"),
2666 }
2667 }
2668
2669 #[test]
2670 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
2671 let candidate_commit = "magi: candidate A (uncommitted work)";
2672 let subject = merge_subject(candidate_commit, "add retries to the uploader");
2673 let argv = merge_argv(16, &subject);
2674
2675 assert!(argv.contains(&"--squash".to_owned()));
2676 assert!(argv.contains(&"--delete-branch".to_owned()));
2677 assert!(argv.contains(&"--subject".to_owned()));
2678 assert_eq!(
2679 argv.last().map(String::as_str),
2680 Some("add retries to the uploader"),
2681 "the subject must not be the candidate commit message"
2682 );
2683 assert_ne!(subject, candidate_commit);
2684 }
2685
2686 #[test]
2687 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
2688 assert_eq!(
2689 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
2690 "feat: a queue, an unattended loop, and a phone UI"
2691 );
2692 assert_eq!(
2693 merge_subject("", "# port the retry logic\n\ndetails"),
2694 "port the retry logic",
2695 "an empty title falls back to the task's first line, heading marks stripped"
2696 );
2697 }
2698
2699 #[test]
2700 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
2701 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
2702 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
2703 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
2704 assert_eq!(job_of("https://coderabbit.ai/status"), None);
2705 assert_eq!(run_of(""), None);
2706 }
2707
2708 #[test]
2709 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
2710 let mut out = Vec::new();
2711 push_if_outstanding(
2712 &mut out,
2713 ReviewComment {
2714 author: "yukimemi".to_owned(),
2715 path: None,
2716 line: None,
2717 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
2718 },
2719 );
2720 assert!(out.is_empty());
2721 }
2722
2723 fn run_state() -> RunState {
2727 RunState::new(
2728 std::path::PathBuf::from("/repo/magi"),
2729 "main".to_owned(),
2730 "abcdef1234".to_owned(),
2731 "add retries to the uploader".to_owned(),
2732 crate::config::Config::default(),
2733 )
2734 }
2735
2736 fn green_pr() -> PrState {
2737 PrState {
2738 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
2739 number: 42,
2740 state: PrLifecycle::Open,
2741 checks: Checks::Green,
2742 blocking: Blocking::No,
2744 failing: Vec::new(),
2745 review_comments: vec![ReviewComment {
2746 author: "coderabbitai".to_owned(),
2747 path: Some("src/land.rs".to_owned()),
2748 line: Some(212),
2749 body: "this branch never checks the exit code".to_owned(),
2750 }],
2751 }
2752 }
2753
2754 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
2755
2756 fn panel() -> String {
2757 approval_panel(
2758 &run_state(),
2759 &green_pr(),
2760 NUMSTAT,
2761 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
2762 &[
2763 "land: ask before merging".to_owned(),
2764 "land: colour the diff".to_owned(),
2765 ],
2766 "feat: merge approval from the phone",
2767 )
2768 }
2769
2770 #[test]
2771 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
2772 let html = panel();
2773 for needle in [
2774 "42",
2775 "main",
2776 "src/land.rs",
2777 "src/web.rs",
2778 "assets/logo.png",
2779 "feat: merge approval from the phone",
2780 "land: ask before merging",
2781 "land: colour the diff",
2782 "coderabbitai",
2783 "this branch never checks the exit code",
2784 "green",
2785 ] {
2786 assert!(html.contains(needle), "the panel must state `{needle}`");
2787 }
2788 }
2789
2790 #[test]
2791 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
2792 let html = panel();
2793 assert!(!html.contains("<script"), "no script survives the csp");
2794 assert!(!html.contains("<form"), "form-action is 'none'");
2795 let pr = green_pr();
2796 assert_eq!(
2797 html.matches("http").count(),
2798 html.matches(pr.url.as_str()).count(),
2799 "the only http url in the panel is the pull request's own link"
2800 );
2801 }
2802
2803 #[test]
2804 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
2805 let html = panel();
2806 assert!(
2807 html.contains(">+</span>"),
2808 "an added line carries a `+` in the gutter, not only a background"
2809 );
2810 assert!(
2811 html.contains(">-</span>"),
2812 "a removed line carries a `-` in the gutter, not only a background"
2813 );
2814 assert!(
2815 html.contains(">new line</span>"),
2816 "the marker is moved to the gutter, so the body is printed once without it"
2817 );
2818 }
2819
2820 #[test]
2821 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
2822 let total = DIFF_MAX_LINES + 100;
2823 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
2824 let html = approval_panel(
2825 &run_state(),
2826 &green_pr(),
2827 NUMSTAT,
2828 &diff,
2829 &[],
2830 "feat: something long",
2831 );
2832 assert!(
2833 html.contains(&format!("100 of {total} diff lines omitted")),
2834 "the note must say exactly how much was cut"
2835 );
2836 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
2837 assert!(
2838 !html.contains(&format!("line {DIFF_MAX_LINES}")),
2839 "nothing past the threshold is rendered"
2840 );
2841 assert!(
2842 html.contains("/repo/magi"),
2843 "the note says where the rest is"
2844 );
2845 }
2846
2847 #[test]
2848 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
2849 let html = approval_panel(
2850 &run_state(),
2851 &green_pr(),
2852 "1\t2\tsrc/<b>&\"x\"'.rs",
2853 "",
2854 &[],
2855 "subject",
2856 );
2857 assert!(html.contains("src/<b>&"x"'.rs"));
2858 assert!(
2859 !html.contains("<b>"),
2860 "an agent-influenced path must never become markup"
2861 );
2862 }
2863
2864 #[test]
2865 fn only_the_merge_choice_merges_and_silence_holds() {
2866 let table = [
2867 (None, Approval::Hold),
2868 (Some("merge"), Approval::Merge),
2869 (Some(" merge\n"), Approval::Merge),
2870 (Some("hold"), Approval::Hold),
2871 (Some(""), Approval::Hold),
2872 (Some("yes"), Approval::Hold),
2873 ];
2874 for (answer, want) in table {
2875 assert_eq!(
2876 approval(answer),
2877 want,
2878 "answer {answer:?} must resolve to {want:?}"
2879 );
2880 }
2881 }
2882
2883 #[test]
2884 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
2885 let rows = parse_numstat(NUMSTAT);
2886 assert_eq!(
2887 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
2888 ["src/web.rs", "src/land.rs", "assets/logo.png"]
2889 );
2890 assert_eq!(rows[2].added, None, "a binary file has no line counts");
2891 }
2892 #[test]
2893 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
2894 let mut state = run_state();
2898 state.config.graph.language = "ja".to_owned();
2899 let pr = green_pr();
2900 let commits = ["c1".to_owned()];
2901
2902 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
2903 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
2904 assert!(ja.contains("squash されるコミット"), "{ja}");
2905 assert!(ja.contains("レビューコメント"), "{ja}");
2906 assert!(ja.contains("差分"), "{ja}");
2907 assert!(
2908 !ja.contains("Commits being squashed"),
2909 "no English left over"
2910 );
2911
2912 let w = words("ja");
2913 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
2914 assert!(
2915 w.approval_detail("http://x/1", "main", "feat: x")
2916 .contains("パネル")
2917 );
2918
2919 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
2921 assert!(ja.contains("feat: x"), "nor is the merge subject");
2922
2923 state.config.graph.language = "en".to_owned();
2926 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
2927 assert!(en.contains("Commits being squashed"), "{en}");
2928 assert_eq!(words("Klingon").html_lang, "en");
2929 }
2930}