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