1use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{MergeOutcome, RunState, RunStatus, tail};
58
59pub const POLL: Duration = Duration::from_secs(30);
65
66pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86const LOG_TAIL: usize = 4_000;
89
90const MAX_LOGS: usize = 3;
93
94pub const MARKER: &str = "<!-- magi:land -->";
100
101const NOT_A_REVIEW: [&str; 3] = [
110 "skip review by coderabbit.ai",
111 "summarize by coderabbit.ai",
112 "<!-- tips_start -->",
113];
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118 Open,
120 Merged,
122 Closed,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129 Pending,
131 Green,
134 Red,
136 Unknown,
138}
139
140impl PrLifecycle {
141 pub fn as_str(self) -> &'static str {
143 match self {
144 Self::Open => "open",
145 Self::Merged => "merged",
146 Self::Closed => "closed",
147 }
148 }
149}
150
151impl Checks {
152 pub fn as_str(self) -> &'static str {
154 match self {
155 Self::Pending => "pending",
156 Self::Green => "green",
157 Self::Red => "red",
158 Self::Unknown => "unknown",
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166 pub author: String,
168 pub path: Option<String>,
170 pub line: Option<u64>,
172 pub body: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179 pub url: String,
181 pub number: u64,
183 pub state: PrLifecycle,
185 pub checks: Checks,
187 pub failing: Vec<String>,
189 pub review_comments: Vec<ReviewComment>,
191 pub blocking: Blocking,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209 No,
211 Yes,
213 Conflict,
215 Unsaid,
219}
220
221impl Blocking {
222 fn of(raw: &str) -> Self {
224 match raw.to_ascii_uppercase().as_str() {
225 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228 "DIRTY" => Self::Conflict,
229 "" | "UNKNOWN" => Self::Unsaid,
230 _ => Self::Yes,
232 }
233 }
234
235 #[must_use]
237 pub fn stops_a_merge(self) -> bool {
238 !matches!(self, Self::No)
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245 Wait,
247 Rebase,
255 Fix {
257 reason: String,
259 },
260 Merge,
262 Done {
264 merged: bool,
266 },
267 GiveUp {
269 reason: String,
271 },
272}
273
274pub(crate) fn merged_after_all(
296 argv: &[String],
297 stderr: &str,
298 after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300 if after? != PrLifecycle::Merged {
301 return None;
302 }
303 Some(MergeOutcome {
304 mode: MergeMode::Pr,
305 ok: true,
306 detail: format!(
307 "gh {} (the command reported `{}`, but the pull request is merged)",
308 argv.join(" "),
309 stderr.trim()
310 ),
311 empty: false,
312 })
313}
314
315pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
335 match pr.state {
336 PrLifecycle::Merged => return Step::Done { merged: true },
337 PrLifecycle::Closed => return Step::Done { merged: false },
338 PrLifecycle::Open => {}
339 }
340
341 if pr.blocking == Blocking::Conflict {
344 return Step::Rebase;
345 }
346
347 let spent = round >= budget;
348 match pr.checks {
349 Checks::Pending => Step::Wait,
350 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
351 Checks::Unknown => Step::GiveUp {
352 reason: format!(
353 "no check status is readable on the pull request after {} minute(s); \
354 refusing to merge on a guess",
355 CHECKS_GRACE.as_secs() / 60
356 ),
357 },
358 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
365 Checks::Red => {
366 let what = format!(
367 "{} check(s) failing: {}",
368 pr.failing.len(),
369 pr.failing.join(", ")
370 );
371 if spent {
372 Step::GiveUp {
373 reason: format!("{what} — still red after {budget} fix round(s)"),
374 }
375 } else {
376 Step::Fix { reason: what }
377 }
378 }
379 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
380 Checks::Green => {
381 let what = format!(
382 "checks are green but {} review comment(s) are unresolved: {}",
383 pr.review_comments.len(),
384 authors(&pr.review_comments)
385 );
386 if spent {
387 Step::GiveUp {
388 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
389 }
390 } else {
391 Step::Fix { reason: what }
392 }
393 }
394 }
395}
396
397fn authors(comments: &[ReviewComment]) -> String {
399 let mut seen: Vec<&str> = Vec::new();
400 for c in comments {
401 if !seen.contains(&c.author.as_str()) {
402 seen.push(&c.author);
403 }
404 }
405 seen.join(", ")
406}
407
408pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
412 vec![
413 "pr".to_owned(),
414 "merge".to_owned(),
415 number.to_string(),
416 "--squash".to_owned(),
417 "--delete-branch".to_owned(),
418 "--subject".to_owned(),
419 subject.to_owned(),
420 ]
421}
422
423pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
430 let title = pr_title.trim();
431 if !title.is_empty() && !title.starts_with("magi: candidate") {
432 return title.to_owned();
433 }
434 let first = instruction
435 .lines()
436 .map(str::trim)
437 .find(|l| !l.is_empty())
438 .unwrap_or("magi: land the winning candidate");
439 first.trim_start_matches(['#', ' ']).to_owned()
440}
441
442pub const APPROVE: &str = "merge";
444
445pub const HOLD: &str = "hold";
447
448pub const APPROVAL_NODE: &str = "land-approval";
454
455pub const DIFF_MAX_LINES: usize = 400;
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum Approval {
467 Merge,
469 Hold,
471}
472
473pub fn approval(answer: Option<&str>) -> Approval {
481 match answer {
482 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
483 _ => Approval::Hold,
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489enum ApprovalGate {
490 Approved,
492 Held,
495 Pending,
497}
498
499fn esc(s: &str) -> String {
509 let mut out = String::with_capacity(s.len());
510 for c in s.chars() {
511 match c {
512 '&' => out.push_str("&"),
513 '<' => out.push_str("<"),
514 '>' => out.push_str(">"),
515 '"' => out.push_str("""),
516 '\'' => out.push_str("'"),
517 _ => out.push(c),
518 }
519 }
520 out
521}
522
523#[derive(Debug, Clone, PartialEq, Eq)]
525struct StatRow {
526 path: String,
527 added: Option<u64>,
529 removed: Option<u64>,
530}
531
532impl StatRow {
533 fn churn(&self) -> u64 {
536 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
537 }
538}
539
540fn parse_numstat(numstat: &str) -> Vec<StatRow> {
546 let mut rows: Vec<StatRow> = numstat
547 .lines()
548 .filter_map(|line| {
549 let mut parts = line.splitn(3, '\t');
550 let added = parts.next()?.trim();
551 let removed = parts.next()?.trim();
552 let path = parts.next()?.trim();
553 if path.is_empty() {
554 return None;
555 }
556 Some(StatRow {
557 path: path.to_owned(),
558 added: added.parse().ok(),
559 removed: removed.parse().ok(),
560 })
561 })
562 .collect();
563 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
566 rows
567}
568
569fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
578 if line.starts_with("+++") || line.starts_with("---") {
579 (" ", "color:#57606a;font-weight:600", line)
580 } else if let Some(body) = line.strip_prefix('+') {
581 ("+", "background:#e6ffec;color:#0a3622", body)
582 } else if let Some(body) = line.strip_prefix('-') {
583 ("-", "background:#ffebe9;color:#5c1a17", body)
584 } else if line.starts_with("@@") {
585 ("~", "background:#eef2ff;color:#3730a3", line)
586 } else if let Some(body) = line.strip_prefix(' ') {
587 (" ", "", body)
588 } else {
589 (" ", "color:#57606a;font-weight:600", line)
590 }
591}
592
593struct Words {
602 html_lang: &'static str,
603 task: &'static str,
604 what_changed: &'static str,
605 review_verdict: &'static str,
606 reviewer: &'static str,
607 reviewer_no_answer: &'static str,
608 checks: &'static str,
609 nothing_failing: &'static str,
610 files_changed: &'static str,
611 commits: &'static str,
612 no_commits: &'static str,
613 comments: &'static str,
614 no_comments: &'static str,
615 diff: &'static str,
616 truncated: &'static str,
617 lands_as: &'static str,
618}
619
620const EN: Words = Words {
621 html_lang: "en",
622 task: "Task",
623 what_changed: "What changed",
624 review_verdict: "Review verdict",
625 reviewer: "Reviewer",
626 reviewer_no_answer: "produced no answer",
627 checks: "Checks",
628 nothing_failing: "Nothing failing.",
629 files_changed: "file(s) changed",
630 commits: "Commits being squashed",
631 no_commits: "No commit subjects could be read from the branch.",
632 comments: "Review comments",
633 no_comments: "Nothing outstanding at this observation.",
634 diff: "Diff",
635 truncated: "Truncated",
636 lands_as: "They land as one commit titled",
637};
638
639const JA: Words = Words {
640 html_lang: "ja",
641 task: "タスク",
642 what_changed: "変更内容",
643 review_verdict: "レビューの結論",
644 reviewer: "レビュアー",
645 reviewer_no_answer: "回答なし",
646 checks: "チェック",
647 nothing_failing: "失敗しているものはありません。",
648 files_changed: "ファイル変更",
649 commits: "squash されるコミット",
650 no_commits: "ブランチからコミット件名を読めませんでした。",
651 comments: "レビューコメント",
652 no_comments: "この時点で未対応のものはありません。",
653 diff: "差分",
654 truncated: "省略",
655 lands_as: "これらは次の件名の1コミットとして入ります:",
656};
657
658impl Words {
659 fn lands_as_tail(&self) -> &'static str {
663 if self.html_lang == "ja" {
664 "。この件名も承認の対象です。"
665 } else {
666 ", which you are approving too."
667 }
668 }
669
670 fn approval_summary(&self, number: u64, subject: &str) -> String {
672 if self.html_lang == "ja" {
673 format!("プルリクエスト #{number} をマージ: {subject}")
674 } else {
675 format!("merge pull request #{number}: {subject}")
676 }
677 }
678
679 fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
681 if self.html_lang == "ja" {
682 format!(
683 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
684 できる状態です。差分の要約・パッチ・squash されるコミットは\
685 下のパネルにあります。"
686 )
687 } else {
688 format!(
689 "{url} is green and ready to squash into `{base}` as `{subject}`. \
690 The panel holds the diffstat, the patch and the commits being squashed."
691 )
692 }
693 }
694
695 fn truncated_note(
697 &self,
698 omitted: usize,
699 total: usize,
700 shown: usize,
701 where_: &str,
702 base: &str,
703 head: &str,
704 ) -> String {
705 if self.html_lang == "ja" {
706 format!(
707 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
708 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
709 プルリクエストにあります。"
710 )
711 } else {
712 format!(
713 "{omitted} of {total} diff lines omitted after the first {shown}. \
714 The whole patch is in <code>{where_}</code> \
715 (<code>git diff {base}...{head}</code>) and on the pull request."
716 )
717 }
718 }
719}
720
721fn words(language: &str) -> &'static Words {
724 let l = language.trim();
725 if l.eq_ignore_ascii_case("ja")
726 || l.eq_ignore_ascii_case("jp")
727 || l.eq_ignore_ascii_case("japanese")
728 || l.eq_ignore_ascii_case("日本語")
729 {
730 &JA
731 } else {
732 &EN
733 }
734}
735
736pub fn approval_panel(
748 state: &RunState,
749 pr: &PrState,
750 diffstat: &str,
751 diff: &str,
752 commits: &[String],
753 subject: &str,
754) -> String {
755 let rows = parse_numstat(diffstat);
756 let w = words(&state.config.graph.language);
757 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
758
759 let _ = writeln!(
760 h,
761 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
762 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
763 w.html_lang
764 );
765 let _ = writeln!(
766 h,
767 "<title>merge #{} — {}</title>\n</head>",
768 pr.number,
769 esc(subject)
770 );
771 h.push_str(
772 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
773 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
774 word-break:break-word\">\n",
775 );
776
777 let _ = writeln!(
779 h,
780 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
781 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
782 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
783 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
784 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
785 pr.number,
786 esc(&state.base_branch),
787 esc(subject),
788 esc(&state.id),
789 esc(&pr.url),
790 esc(&pr.url),
791 );
792
793 let _ = writeln!(
796 h,
797 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
798 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
799 w.task,
800 esc(&state.instruction)
801 );
802
803 if let Some(summary) = state
805 .winner()
806 .map(|c| c.summary.as_str())
807 .filter(|s| !s.is_empty())
808 {
809 let _ = writeln!(
810 h,
811 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
812 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
813 w.what_changed,
814 esc(summary)
815 );
816 }
817
818 if let Some(round) = state.reviews.last() {
821 let _ = writeln!(
822 h,
823 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
824 w.review_verdict
825 );
826 for r in &round.reviews {
827 let body = match &r.failed {
834 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
835 None => esc(&r.summary),
836 };
837 let _ = writeln!(
838 h,
839 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
840 border-radius:6px\">\
841 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
842 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
843 w.reviewer,
844 r.reviewer,
845 esc(&r.agent),
846 body,
847 );
848 }
849 }
850
851 let _ = writeln!(
852 h,
853 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
854 w.checks,
855 esc(pr.checks.as_str())
856 );
857 if pr.failing.is_empty() {
858 let _ = writeln!(
859 h,
860 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
861 w.nothing_failing
862 );
863 } else {
864 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
865 for f in &pr.failing {
866 let _ = writeln!(h, "<li>{}</li>", esc(f));
867 }
868 h.push_str("</ul>\n");
869 }
870
871 let _ = writeln!(
874 h,
875 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
876 rows.len(),
877 w.files_changed
878 );
879 h.push_str(
880 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
881 <thead><tr>\
882 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
883 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
884 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
885 </th></tr></thead>\n<tbody>\n",
886 );
887 let mut total_added = 0u64;
888 let mut total_removed = 0u64;
889 for r in &rows {
890 total_added += r.added.unwrap_or(0);
891 total_removed += r.removed.unwrap_or(0);
892 let cell = |n: Option<u64>| match n {
893 Some(n) => n.to_string(),
894 None => "bin".to_owned(),
895 };
896 let _ = writeln!(
897 h,
898 "<tr>\
899 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
900 font-family:ui-monospace,monospace\">{}</td>\
901 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
902 color:#0a3622\">{}</td>\
903 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
904 color:#5c1a17\">{}</td></tr>",
905 esc(&r.path),
906 cell(r.added),
907 cell(r.removed),
908 );
909 }
910 let _ = writeln!(
911 h,
912 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
913 <td style=\"padding:4px 2px\">total</td>\
914 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
915 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
916 </tr></tfoot>\n</table>"
917 );
918
919 let _ = writeln!(
921 h,
922 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
923 w.commits
924 );
925 if commits.is_empty() {
926 h.push_str(&format!(
927 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
928 w.no_commits
929 ));
930 } else {
931 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
932 for c in commits {
933 let _ = writeln!(h, "<li>{}</li>", esc(c));
934 }
935 h.push_str("</ol>\n");
936 }
937 let _ = writeln!(
938 h,
939 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
940 w.lands_as,
941 esc(subject),
942 w.lands_as_tail()
943 );
944
945 let _ = writeln!(
947 h,
948 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
949 w.comments
950 );
951 if pr.review_comments.is_empty() {
952 h.push_str(&format!(
953 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
954 w.no_comments
955 ));
956 } else {
957 for c in &pr.review_comments {
958 let anchor = match (&c.path, c.line) {
959 (Some(p), Some(l)) => format!("{p}:{l}"),
960 (Some(p), None) => p.clone(),
961 _ => "pull request thread".to_owned(),
962 };
963 let _ = writeln!(
964 h,
965 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
966 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
967 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
968 esc(&c.author),
969 esc(&anchor),
970 esc(&tail(&c.body, 800)),
971 );
972 }
973 }
974
975 let total = diff.lines().count();
977 let shown = total.min(DIFF_MAX_LINES);
978 let _ = writeln!(
979 h,
980 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
981 w.diff
982 );
983 h.push_str(
984 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
985 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
986 );
987 for line in diff.lines().take(shown) {
988 let (gutter, style, body) = diff_row(line);
989 let _ = writeln!(
990 h,
991 "<div style=\"display:flex;{style}\">\
992 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
993 border-right:1px solid #d0d7de\">{gutter}</span>\
994 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
995 esc(body),
996 );
997 }
998 h.push_str("</div>\n");
999 if total > shown {
1000 let omitted = total - shown;
1001 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1002 let where_ = state.winner().map_or_else(
1003 || state.repo.display().to_string(),
1004 |w| w.worktree.display().to_string(),
1005 );
1006 let _ = writeln!(
1007 h,
1008 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1009 font-size:13px\">{}: {}</p>",
1010 w.truncated,
1011 w.truncated_note(
1012 omitted,
1013 total,
1014 shown,
1015 &esc(&where_),
1016 &esc(&state.base_branch),
1017 &esc(head),
1018 ),
1019 );
1020 }
1021
1022 h.push_str("</body>\n</html>\n");
1023 h
1024}
1025
1026async fn approval_gate(state: &mut RunState, pr: &PrState, subject: &str) -> Result<ApprovalGate> {
1046 let store = ask::Questions::open();
1047 let existing = store
1048 .list()
1049 .into_iter()
1050 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
1051 .max_by(|a, b| a.id.cmp(&b.id));
1052
1053 let q = match existing {
1054 Some(q) => q,
1055 None => {
1056 let (worktree, head) = match state.winner() {
1057 Some(w) => (w.worktree.clone(), w.branch.clone()),
1058 None => (state.repo.clone(), "HEAD".to_owned()),
1059 };
1060 let base = state.base_branch.clone();
1061 let range = format!("{base}...{head}");
1062 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1066 .await
1067 .map(|o| o.stdout)
1068 .unwrap_or_default();
1069 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1070 let commits: Vec<String> = git::git_raw(
1071 &worktree,
1072 &[
1073 "log",
1074 "--reverse",
1075 "--format=%s",
1076 &format!("{base}..{head}"),
1077 ],
1078 )
1079 .await
1080 .map(|o| o.stdout)
1081 .unwrap_or_default()
1082 .lines()
1083 .filter(|l| !l.trim().is_empty())
1084 .map(str::to_owned)
1085 .collect();
1086
1087 let w = words(&state.config.graph.language);
1088 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1089 let mut fresh = ask::Question::new(
1090 state.id.clone(),
1091 APPROVAL_NODE.to_owned(),
1092 "land".to_owned(),
1093 w.approval_summary(pr.number, subject),
1094 w.approval_detail(&pr.url, &base, subject),
1095 vec![APPROVE.to_owned(), HOLD.to_owned()],
1096 );
1097 store
1098 .put_panel(&mut fresh, &html, &[])
1099 .context("write the merge approval panel")?;
1100 store
1101 .put(&mut fresh)
1102 .context("file the merge approval question")?;
1103 state.event(
1104 "land",
1105 format!("asking for merge approval ({})", fresh.short()),
1106 );
1107 state.save()?;
1108 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1109 tracing::warn!(
1113 "could not notify about merge approval question {}: {e:#} - \
1114 the web UI is the only surface for it now",
1115 fresh.short()
1116 );
1117 }
1118 fresh
1119 }
1120 };
1121
1122 Ok(match q.status {
1123 ask::QuestionStatus::Open => ApprovalGate::Pending,
1124 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1128 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1132 Approval::Merge => ApprovalGate::Approved,
1133 Approval::Hold => ApprovalGate::Held,
1134 },
1135 })
1136}
1137
1138pub fn parse_pr(json: &str) -> Result<PrState> {
1141 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1142 let state = match raw.state.to_ascii_uppercase().as_str() {
1143 "OPEN" => PrLifecycle::Open,
1144 "MERGED" => PrLifecycle::Merged,
1145 "CLOSED" => PrLifecycle::Closed,
1146 other => bail!("unknown pull request state `{other}`"),
1147 };
1148
1149 let mut failing = Vec::new();
1150 let mut pending = false;
1151 let mut unknown = false;
1152 for check in &raw.status_check_rollup {
1153 match check.verdict() {
1154 Verdict::Pass => {}
1155 Verdict::Pending => pending = true,
1156 Verdict::Fail => failing.push(check.label()),
1157 Verdict::Unknown => unknown = true,
1158 }
1159 }
1160 let checks = if raw.status_check_rollup.is_empty() {
1161 Checks::Unknown
1162 } else if pending {
1163 Checks::Pending
1164 } else if !failing.is_empty() {
1165 Checks::Red
1166 } else if unknown {
1167 Checks::Unknown
1168 } else {
1169 Checks::Green
1170 };
1171
1172 let mut review_comments = Vec::new();
1173 for r in raw.reviews {
1174 push_if_outstanding(
1175 &mut review_comments,
1176 ReviewComment {
1177 author: r.author.login,
1178 path: None,
1179 line: None,
1180 body: r.body,
1181 },
1182 );
1183 }
1184 for c in raw.comments {
1185 push_if_outstanding(
1186 &mut review_comments,
1187 ReviewComment {
1188 author: c.author.login,
1189 path: None,
1190 line: None,
1191 body: c.body,
1192 },
1193 );
1194 }
1195
1196 Ok(PrState {
1197 url: raw.url,
1198 number: raw.number,
1199 state,
1200 checks,
1201 failing,
1202 review_comments,
1203 blocking: Blocking::of(&raw.merge_state_status),
1204 })
1205}
1206
1207pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1217 let view = gh(
1218 repo,
1219 &[
1220 "pr".to_owned(),
1221 "view".to_owned(),
1222 pr_url.to_owned(),
1223 "--json".to_owned(),
1224 "state".to_owned(),
1225 ],
1226 )
1227 .await?;
1228 if !view.0 {
1229 bail!("gh pr view {pr_url}: {}", view.1);
1230 }
1231 Ok(parse_pr(&view.1)?.state)
1235}
1236
1237#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1241pub struct ExternalMerge {
1242 pub url: String,
1244 pub number: u64,
1246}
1247
1248#[derive(Debug, Deserialize)]
1249#[serde(rename_all = "camelCase")]
1250struct GhMergedPr {
1251 url: String,
1252 number: u64,
1253 merged_at: String,
1254 base_ref_name: String,
1255}
1256
1257fn pick_merged_pr(
1271 json: &str,
1272 base_branch: &str,
1273 created_at: Timestamp,
1274) -> Result<Option<ExternalMerge>> {
1275 let raw: Vec<GhMergedPr> =
1276 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1277 let mut matches: Vec<ExternalMerge> = Vec::new();
1278 for pr in raw {
1279 if pr.base_ref_name != base_branch {
1280 continue;
1281 }
1282 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1283 continue;
1284 };
1285 if merged_at < created_at {
1286 continue;
1287 }
1288 matches.push(ExternalMerge {
1289 url: pr.url,
1290 number: pr.number,
1291 });
1292 }
1293 if matches.len() == 1 {
1294 Ok(matches.pop())
1295 } else {
1296 Ok(None)
1297 }
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Eq)]
1302pub enum OpenPr {
1303 None,
1305 One {
1307 url: String,
1309 title: String,
1311 },
1312 Many(Vec<String>),
1314}
1315
1316#[derive(Debug, Deserialize)]
1317#[serde(rename_all = "camelCase")]
1318struct GhOpenPr {
1319 url: String,
1323 #[serde(default)]
1324 title: String,
1325 base_ref_name: String,
1326}
1327
1328pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1334 let raw: Vec<GhOpenPr> =
1335 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1336 let mut hits: Vec<GhOpenPr> = raw
1337 .into_iter()
1338 .filter(|p| p.base_ref_name == base)
1339 .collect();
1340 Ok(match hits.len() {
1341 0 => OpenPr::None,
1342 1 => {
1343 let p = hits.remove(0);
1344 OpenPr::One {
1345 url: p.url,
1346 title: p.title,
1347 }
1348 }
1349 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1350 })
1351}
1352
1353pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1357 let (ok, out) = gh(
1358 repo,
1359 &[
1360 "pr".to_owned(),
1361 "list".to_owned(),
1362 "--head".to_owned(),
1363 branch.to_owned(),
1364 "--base".to_owned(),
1365 base.to_owned(),
1366 "--state".to_owned(),
1367 "open".to_owned(),
1368 "--json".to_owned(),
1369 "number,url,title,baseRefName".to_owned(),
1370 ],
1371 )
1372 .await?;
1373 if !ok {
1374 bail!("gh pr list failed: {out}");
1375 }
1376 pick_open_pr(&out, base)
1377}
1378
1379pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
1383 let (ok, out) = gh(
1384 repo,
1385 &[
1386 "pr".to_owned(),
1387 "edit".to_owned(),
1388 url.to_owned(),
1389 "--title".to_owned(),
1390 title.to_owned(),
1391 ],
1392 )
1393 .await?;
1394 if !ok {
1395 bail!("gh pr edit failed: {out}");
1396 }
1397 Ok(())
1398}
1399
1400pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1413 let Some(winner) = state.winner() else {
1414 return Ok(None);
1415 };
1416 let branch = winner.branch.clone();
1417 let out = gh(
1418 &state.repo,
1419 &[
1420 "pr".to_owned(),
1421 "list".to_owned(),
1422 "--head".to_owned(),
1423 branch.clone(),
1424 "--state".to_owned(),
1425 "merged".to_owned(),
1426 "--json".to_owned(),
1427 "url,number,mergedAt,baseRefName".to_owned(),
1428 ],
1429 )
1430 .await?;
1431 if !out.0 {
1432 bail!("gh pr list --head {branch}: {}", out.1);
1433 }
1434 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1435}
1436
1437pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1449 let out = tokio::process::Command::new("git")
1450 .args(["merge-base", "--is-ancestor", branch, base_branch])
1451 .current_dir(repo)
1452 .quiet()
1453 .stdin(std::process::Stdio::null())
1454 .output()
1455 .await
1456 .context("spawn git merge-base --is-ancestor")?;
1457 Ok(out.status.success())
1458}
1459
1460fn forge_slug(url: &str) -> Option<(String, &str)> {
1468 let rest = url.rsplit("://").next()?;
1469 let (host, path) = rest.split_once('/')?;
1470 if host.is_empty() {
1471 return None;
1472 }
1473 Some((host.to_ascii_lowercase(), path))
1474}
1475
1476pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
1484 let (host, path) = forge_slug(url)?;
1485 let mut segments = path.split('/');
1486 let owner = segments.next()?;
1487 let repo = segments.next()?;
1488 let kind = segments.next()?;
1489 if owner.is_empty() || repo.is_empty() || kind != "pull" {
1490 return None;
1491 }
1492 Some(format!("{host}/{owner}/{repo}"))
1493}
1494
1495fn slug_of_repo_url(url: &str) -> Option<String> {
1499 let (host, path) = forge_slug(url)?;
1500 let mut segments = path.split('/');
1501 let owner = segments.next()?;
1502 let repo = segments.next()?;
1503 if owner.is_empty() || repo.is_empty() {
1504 return None;
1505 }
1506 Some(format!("{host}/{owner}/{repo}"))
1507}
1508
1509pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
1530 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
1531 return Ok(());
1532 }
1533 bail!(
1534 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
1535 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
1536 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
1537 verify by hand before treating this as a false positive)"
1538 );
1539}
1540
1541async fn repo_slug(repo: &Path) -> Result<String> {
1552 let out = gh(
1553 repo,
1554 &[
1555 "repo".to_owned(),
1556 "view".to_owned(),
1557 "--json".to_owned(),
1558 "url".to_owned(),
1559 ],
1560 )
1561 .await?;
1562 if !out.0 {
1563 bail!("gh repo view --json url: {}", out.1);
1564 }
1565 #[derive(Debug, Deserialize)]
1566 struct GhRepo {
1567 url: String,
1568 }
1569 let parsed: GhRepo = serde_json::from_str(&out.1)
1570 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
1571 slug_of_repo_url(&parsed.url)
1572 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
1573}
1574
1575pub async fn correct_manual_merge(
1622 state: &mut RunState,
1623 url: &str,
1624) -> Result<(RunStatus, RunStatus)> {
1625 let Some(pr_slug) = slug_of_pr_url(url) else {
1626 bail!(
1627 "could not parse an owner/repo out of {url}; refusing to guess which repository \
1628 this pull request belongs to"
1629 );
1630 };
1631 let run_slug = repo_slug(&state.repo).await?;
1632 ensure_same_repo(&run_slug, &pr_slug)?;
1633 correct_merge(state, url).await
1634}
1635
1636pub(crate) async fn correct_confirmed_external_merge(
1647 state: &mut RunState,
1648 url: &str,
1649) -> Result<(RunStatus, RunStatus)> {
1650 correct_merge(state, url).await
1651}
1652
1653async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
1654 match lifecycle(&state.repo, url).await? {
1655 PrLifecycle::Merged => {}
1656 other => bail!(
1657 "{url} is {}, not merged; refusing to record {} as merged on a guess",
1658 other.as_str(),
1659 state.id
1660 ),
1661 }
1662 let before = state.status;
1663 if let Err(e) = land(state, url).await {
1664 state.status = RunStatus::Blocked;
1671 state.event("fold", format!("manual-merge correction failed: {e:#}"));
1672 state.save()?;
1673 return Err(e).context(format!("confirming the merge of {url}"));
1674 }
1675 state.event(
1676 "fold",
1677 "operator recorded this pull request as a manual merge; this run never \
1678 re-entered `land`, so `bump::after_merge` did not run for it - a release \
1679 bump this change might warrant has to be filed by hand",
1680 );
1681 state.save()?;
1682 Ok((before, state.status))
1683}
1684
1685pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1692 let raw: Vec<GhInline> =
1693 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1694 let mut out = Vec::new();
1695 for c in raw {
1696 push_if_outstanding(
1697 &mut out,
1698 ReviewComment {
1699 author: c.user.login,
1700 path: c.path,
1701 line: c.line,
1702 body: c.body,
1703 },
1704 );
1705 }
1706 Ok(out)
1707}
1708
1709fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1715 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1716 return;
1717 }
1718 if comment.path.is_none() && is_noise(&comment.body) {
1719 return;
1720 }
1721 out.push(comment);
1722}
1723
1724pub fn is_noise(body: &str) -> bool {
1742 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1743 return true;
1744 }
1745 let mut content = false;
1746 for line in strip_blocks(body).lines() {
1747 let line = unquote(line);
1748 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1749 continue;
1750 }
1751 content = true;
1752 break;
1753 }
1754 !content
1755}
1756
1757fn strip_blocks(body: &str) -> String {
1759 let mut out = String::with_capacity(body.len());
1760 let mut rest = body;
1761 loop {
1762 let open = ["<!--", "<details>"]
1763 .iter()
1764 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1765 .min_by_key(|(i, _)| *i);
1766 let Some((at, tag)) = open else {
1767 out.push_str(rest);
1768 return out;
1769 };
1770 out.push_str(&rest[..at]);
1771 let after = &rest[at + tag.len()..];
1772 let close = if tag == "<!--" { "-->" } else { "</details>" };
1773 match after.find(close) {
1774 Some(end) => rest = &after[end + close.len()..],
1775 None => return out,
1777 }
1778 }
1779}
1780
1781fn unquote(line: &str) -> &str {
1783 let mut s = line.trim();
1784 while let Some(rest) = s.strip_prefix('>') {
1785 s = rest.trim_start();
1786 }
1787 s.trim()
1788}
1789
1790fn is_checklist(line: &str) -> bool {
1792 let rest = line
1793 .strip_prefix("- ")
1794 .or_else(|| line.strip_prefix("* "))
1795 .unwrap_or("");
1796 let rest = rest.trim_start();
1797 matches!(
1798 rest.get(..3),
1799 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1800 )
1801}
1802
1803fn is_decoration(line: &str) -> bool {
1805 line.starts_with('#')
1806 || line.starts_with("[!")
1807 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1808}
1809
1810fn is_banner(line: &str) -> bool {
1817 let plain = drop_spans(line, "**", "**");
1818 let plain = if plain.contains("](") {
1819 drop_spans(&plain, "[", ")")
1820 } else {
1821 plain
1822 };
1823 !plain.chars().any(char::is_alphanumeric)
1824}
1825
1826fn drop_spans(s: &str, open: &str, close: &str) -> String {
1830 let mut out = String::with_capacity(s.len());
1831 let mut rest = s;
1832 while let Some(at) = rest.find(open) {
1833 out.push_str(&rest[..at]);
1834 let after = &rest[at + open.len()..];
1835 match after.find(close) {
1836 Some(end) => rest = &after[end + close.len()..],
1837 None => return out,
1838 }
1839 }
1840 out.push_str(rest);
1841 out
1842}
1843
1844fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
1862 static LOCKS: std::sync::LazyLock<
1863 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
1864 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
1865 LOCKS
1866 .lock()
1867 .unwrap_or_else(std::sync::PoisonError::into_inner)
1868 .entry(repo.to_path_buf())
1869 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1870 .clone()
1871}
1872
1873fn repo_label(repo: &Path, pr_url: &str) -> String {
1876 let parts: Vec<&str> = pr_url.split('/').collect();
1877 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
1878 && at >= 2
1879 && !parts[at - 1].is_empty()
1880 && !parts[at - 2].is_empty()
1881 {
1882 return format!("{}/{}", parts[at - 2], parts[at - 1]);
1883 }
1884 repo.file_name()
1885 .map(|n| n.to_string_lossy().into_owned())
1886 .unwrap_or_default()
1887}
1888
1889fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
1893 (pr.checks == Checks::Red).then(|| {
1894 format!(
1895 "Merged {repo_name} PR #{} with red checks: {} ({})",
1896 pr.number,
1897 if pr.failing.is_empty() {
1898 "(none named)".to_owned()
1899 } else {
1900 pr.failing.join(", ")
1901 },
1902 pr.url
1903 )
1904 })
1905}
1906
1907async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
1911 let repo_name = repo_label(&state.repo, &pr.url);
1912 let Some(summary) = red_merge_summary(&repo_name, pr) else {
1913 return;
1914 };
1915 if let Some(rec) = state.pr.as_mut() {
1916 rec.red_at_merge = pr.failing.clone();
1917 }
1918 state.event("land", summary.clone());
1919 crate::notices::raise(crate::notices::merged_red(&state.id, &summary));
1920 if let Err(e) = ask::notify_text(&state.config.notify, &state.id, &summary).await {
1921 tracing::warn!("could not notify about a merge with red checks: {e:#}");
1922 }
1923}
1924
1925pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1932 let repo = state.repo.clone();
1933 let budget = state.config.graph.land_rounds;
1934 let mut round = 0usize;
1935 let mut rebases = 0usize;
1938 let mut waited = Duration::ZERO;
1939 let mut shown: BTreeSet<String> = BTreeSet::new();
1944
1945 state.status = RunStatus::Landing;
1953 state.event("land", format!("watching {pr_url}"));
1954 state.save()?;
1955
1956 loop {
1957 let seen = observe(&repo, pr_url).await?;
1958 let mut pr = seen.pr;
1959 pr.review_comments.retain(|c| !shown.contains(&c.body));
1960 state.pr = Some(crate::run::PrRecord {
1961 url: pr.url.clone(),
1962 number: pr.number,
1963 state: pr.state.as_str().to_owned(),
1964 checks: pr.checks.as_str().to_owned(),
1965 round,
1966 rounds: budget,
1967 red_at_merge: Vec::new(),
1968 });
1969 state.save()?;
1970
1971 match decide(&pr, round, budget, waited) {
1972 Step::Wait => {
1973 if waited >= WAIT_CEILING {
1974 let why = format!(
1975 "checks were still running after {} minutes",
1976 WAIT_CEILING.as_secs() / 60
1977 );
1978 stop(state, &repo, &pr, &why).await?;
1979 return Ok(pr);
1980 }
1981 waited += POLL;
1982 tokio::time::sleep(POLL).await;
1983 }
1984 Step::Done { merged } => {
1985 state.status = if merged {
1986 RunStatus::Merged
1987 } else {
1988 RunStatus::Ready
1989 };
1990 let detail = if merged {
1991 format!("{} was merged", pr.url)
1992 } else {
1993 format!("{} was closed without merging", pr.url)
1994 };
1995 state.merge = Some(MergeOutcome {
1996 mode: MergeMode::Pr,
1997 ok: merged,
1998 detail: detail.clone(),
1999 empty: false,
2000 });
2001 state.event("land", detail);
2002 state.save()?;
2003 return Ok(pr);
2004 }
2005 Step::Merge => {
2006 let subject = merge_subject(&seen.title, &state.instruction);
2007 if state.config.graph.land_approval {
2010 match approval_gate(state, &pr, &subject).await? {
2011 ApprovalGate::Approved => {}
2012 ApprovalGate::Held => {
2013 stop(
2014 state,
2015 &repo,
2016 &pr,
2017 "the owner did not approve the merge (held or unanswered)",
2018 )
2019 .await?;
2020 return Ok(pr);
2021 }
2022 ApprovalGate::Pending => {
2030 state.parked = true;
2031 state.event(
2032 "land",
2033 "parked awaiting merge approval - resumes once answered",
2034 );
2035 state.save()?;
2036 return Ok(pr);
2037 }
2038 }
2039 }
2040 let argv = merge_argv(pr.number, &subject);
2041 let out = {
2042 let merge_lock = repo_merge_lock(&repo);
2043 let _merge_slot = merge_lock.lock().await;
2044 gh(&repo, &argv).await?
2045 };
2046 if out.0 {
2047 pr.state = PrLifecycle::Merged;
2048 state.status = RunStatus::Merged;
2049 state.merge = Some(MergeOutcome {
2050 mode: MergeMode::Pr,
2051 ok: true,
2052 detail: format!("gh {}", argv.join(" ")),
2053 empty: false,
2054 });
2055 if let Some(pr_record) = state.pr.as_mut() {
2060 pr_record.state = pr.state.as_str().to_owned();
2061 }
2062 state.event("land", format!("merged {} as `{subject}`", pr.url));
2063 announce_red_merge(state, &pr).await;
2064 state.save()?;
2065 return Ok(pr);
2066 }
2067 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
2068 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
2069 pr.state = PrLifecycle::Merged;
2070 state.status = RunStatus::Merged;
2071 state.merge = Some(outcome);
2072 if let Some(pr_record) = state.pr.as_mut() {
2073 pr_record.state = pr.state.as_str().to_owned();
2074 }
2075 state.event("land", format!("merged {} as `{subject}`", pr.url));
2076 announce_red_merge(state, &pr).await;
2077 state.save()?;
2078 return Ok(pr);
2079 }
2080 stop(
2081 state,
2082 &repo,
2083 &pr,
2084 &format!("`gh pr merge` failed: {}", out.1),
2085 )
2086 .await?;
2087 return Ok(pr);
2088 }
2089 Step::Rebase => {
2090 if rebases >= budget {
2096 let why = format!(
2097 "the base moved under this branch {budget} time(s) and it still does \
2098 not merge; rebasing again would only race it"
2099 );
2100 stop(state, &repo, &pr, &why).await?;
2101 return Ok(pr);
2102 }
2103 rebases += 1;
2104 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
2105 stop(
2106 state,
2107 &repo,
2108 &pr,
2109 "the pull request conflicts and this run has no winning branch to rebase",
2110 )
2111 .await?;
2112 return Ok(pr);
2113 };
2114 let base = state.base_branch.clone();
2115 state.event(
2116 "land",
2117 format!("{} no longer merges; rebasing onto {base}", pr.url),
2118 );
2119 state.save()?;
2120
2121 git::fetch(&repo, "origin", &base).await.ok();
2125 let scratch = state.dir().join("rebase");
2126 let onto = format!("origin/{base}");
2127 let rebased =
2128 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
2129 Ok(crate::rebase::Rebased::Applied) => Ok(None),
2130 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
2131 Err(e) => Err(e),
2132 };
2133 match rebased {
2134 Ok(None) => {
2135 let pushed = {
2136 let merge_lock = repo_merge_lock(&repo);
2137 let _merge_slot = merge_lock.lock().await;
2138 git::push_rewritten(&repo, "origin", &branch).await?
2139 };
2140 if !pushed.ok() {
2141 let why = format!(
2142 "rebased {branch} but could not push it: {}",
2143 pushed.stderr.trim()
2144 );
2145 stop(state, &repo, &pr, &why).await?;
2146 return Ok(pr);
2147 }
2148 state.event("land", format!("rebased {branch} onto {base}"));
2149 state.save()?;
2150 waited = Duration::ZERO;
2153 tokio::time::sleep(POLL).await;
2154 }
2155 Ok(Some(conflict)) => {
2158 let why = format!(
2159 "{} conflicts with {base} and the rebase did not apply: {}",
2160 pr.url,
2161 conflict.chars().take(600).collect::<String>()
2162 );
2163 stop(state, &repo, &pr, &why).await?;
2164 return Ok(pr);
2165 }
2166 Err(e) => {
2167 let why = format!("could not rebase {branch} onto {base}: {e:#}");
2168 stop(state, &repo, &pr, &why).await?;
2169 return Ok(pr);
2170 }
2171 }
2172 }
2173 Step::GiveUp { reason } => {
2174 stop(state, &repo, &pr, &reason).await?;
2175 return Ok(pr);
2176 }
2177 Step::Fix { reason } => {
2178 round += 1;
2179 waited = Duration::ZERO;
2180 for c in &pr.review_comments {
2181 shown.insert(c.body.clone());
2182 }
2183 state.event("land", format!("round {round}: {reason}"));
2184 state.save()?;
2185
2186 let logs = failing_logs(&repo, &seen.failing_urls).await;
2187 let was_red = pr.checks == Checks::Red;
2188 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
2189 Fixed::Committed => {}
2190 Fixed::Declined if was_red => {
2191 let why = format!(
2192 "the fixer produced no commit while {} check(s) were failing \
2193 ({}); stopping instead of looping on an unchanged tree",
2194 pr.failing.len(),
2195 pr.failing.join(", ")
2196 );
2197 stop(state, &repo, &pr, &why).await?;
2198 return Ok(pr);
2199 }
2200 Fixed::Declined => state.event(
2205 "land",
2206 format!("round {round}: fixer declined the comments, nothing committed"),
2207 ),
2208 Fixed::Failed(why) => {
2209 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
2210 return Ok(pr);
2211 }
2212 }
2213 state.save()?;
2214 }
2215 }
2216 }
2217}
2218
2219struct Seen {
2223 pr: PrState,
2224 title: String,
2225 failing_urls: Vec<(String, String)>,
2226}
2227
2228async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
2231 let view = gh(
2232 repo,
2233 &[
2234 "pr".to_owned(),
2235 "view".to_owned(),
2236 pr_url.to_owned(),
2237 "--json".to_owned(),
2238 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
2239 ],
2240 )
2241 .await?;
2242 if !view.0 {
2243 bail!("gh pr view {pr_url}: {}", view.1);
2244 }
2245 let mut pr = parse_pr(&view.1)?;
2246 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
2247
2248 let inline = gh(
2249 repo,
2250 &[
2251 "api".to_owned(),
2252 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
2253 ],
2254 )
2255 .await?;
2256 if inline.0 {
2257 match parse_inline_comments(&inline.1) {
2258 Ok(mut comments) => pr.review_comments.append(&mut comments),
2259 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
2262 }
2263 } else {
2264 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
2265 }
2266
2267 let failing_urls = raw
2268 .status_check_rollup
2269 .iter()
2270 .filter(|c| c.verdict() == Verdict::Fail)
2271 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
2272 .collect();
2273
2274 Ok(Seen {
2275 pr,
2276 title: raw.title,
2277 failing_urls,
2278 })
2279}
2280
2281enum Fixed {
2283 Committed,
2285 Declined,
2287 Failed(String),
2289}
2290
2291async fn fix_round(
2297 state: &mut RunState,
2298 pr: &PrState,
2299 round: usize,
2300 budget: usize,
2301 reason: &str,
2302 logs: &str,
2303) -> Result<Fixed> {
2304 let winner = state
2305 .winner()
2306 .cloned()
2307 .context("landing needs a winning candidate; none is recorded on this run")?;
2308 let roles = state
2309 .config
2310 .resolve_roles()
2311 .context("resolve the roster for the fix round")?;
2312 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
2316 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
2317 _ => (
2318 state
2319 .config
2320 .agent(&winner.agent)
2321 .cloned()
2322 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
2323 format!("impl-{}", winner.label),
2324 ),
2325 };
2326
2327 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
2328 let mut seat = seat_of(state, &seat_key, &spec.id);
2329 let artifacts = agent::artifacts_dir(&state.dir());
2330 let prompt = if state.config.cache_dir().is_some() {
2331 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
2332 } else {
2333 prompt
2334 };
2335 let out = agent::invoke(
2336 &spec,
2337 &mut seat,
2338 &Invocation {
2339 cwd: &winner.worktree,
2340 prompt: &prompt,
2341 timeout: Duration::from_secs(state.config.graph.timeout_fix),
2342 allow_write: true,
2343 sessions: state.config.graph.sessions,
2344 artifacts: &artifacts,
2345 stem: &format!("land-{round}"),
2346 run: &state.id,
2347 node: "land",
2348 cache_dir: state.config.cache_dir().as_deref(),
2349 attachments: &[],
2350 },
2351 )
2352 .await;
2353 state.seats.insert(seat.key.clone(), seat);
2354
2355 match out {
2356 Ok(o) if o.quota_exhausted() => {
2357 return Ok(Fixed::Failed(
2358 "rate limited (quota); the fixer could not run".to_owned(),
2359 ));
2360 }
2361 Ok(o) if !o.usable() => {
2362 return Ok(Fixed::Failed(format!(
2363 "the fixer produced nothing usable (exit {:?}, timed out: {})",
2364 o.exit_code, o.timed_out
2365 )));
2366 }
2367 Ok(_) => {}
2368 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
2369 }
2370
2371 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2372 if let Ok(r) = git::rescue_commit(
2375 &winner.worktree,
2376 &format!("magi: land round {round} fixes (uncommitted work)"),
2377 )
2378 .await
2379 {
2380 state.note_withheld("land", &r.withheld);
2381 }
2382 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2383 if after == before {
2384 return Ok(Fixed::Declined);
2385 }
2386
2387 let remote = state.config.merge.remote.clone();
2388 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
2389 if !push.ok() {
2390 return Ok(Fixed::Failed(format!(
2391 "pushing {} to {remote} failed: {}",
2392 winner.branch, push.stderr
2393 )));
2394 }
2395 state.event(
2396 "land",
2397 format!("round {round}: pushed a fix to {}", winner.branch),
2398 );
2399 Ok(Fixed::Committed)
2400}
2401
2402pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
2404 if let Some(existing) = state.seats.get(key)
2405 && existing.agent == agent
2406 {
2407 return existing.clone();
2408 }
2409 let fresh = SeatState::new(key, agent, state.seed);
2410 state.seats.insert(key.to_owned(), fresh.clone());
2411 fresh
2412}
2413
2414fn fix_prompt(
2416 state: &RunState,
2417 pr: &PrState,
2418 round: usize,
2419 budget: usize,
2420 reason: &str,
2421 logs: &str,
2422) -> String {
2423 let mut s = format!(
2424 "Your patch is open as a pull request and it is not landing. Land round \
2425 {round} of {budget}.\n\n\
2426 Pull request: {}\n\n\
2427 What is holding it: {reason}\n\n\
2428 # The task\n\n{}\n",
2429 pr.url, state.instruction
2430 );
2431
2432 if pr.failing.is_empty() {
2433 s.push_str("\n# Failing checks\n\n(none)\n");
2434 } else {
2435 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
2436 if logs.trim().is_empty() {
2437 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
2438 } else {
2439 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
2440 }
2441 }
2442
2443 if pr.review_comments.is_empty() {
2444 s.push_str("\n# Review comments\n\n(none)\n");
2445 } else {
2446 s.push_str("\n# Review comments\n");
2447 for c in &pr.review_comments {
2448 let where_ = match (&c.path, c.line) {
2449 (Some(p), Some(l)) => format!(" ({p}:{l})"),
2450 (Some(p), None) => format!(" ({p})"),
2451 _ => String::new(),
2452 };
2453 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
2454 }
2455 }
2456
2457 s.push_str(
2458 "\n# Rules\n\n\
2459 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
2460 failing test; do not silence a lint with an allow attribute; do not \
2461 stretch a timeout to hide a race. If the check is right, the code is \
2462 wrong.\n\
2463 2. Change nothing the checks and the comments did not raise. A \
2464 drive-by refactor turns a one-line fix into a pull request that \
2465 needs reviewing again.\n\
2466 3. If a comment is wrong, say so with a checkable argument and change \
2467 nothing for it. A declined comment with a reason is a correct \
2468 outcome; a change made to appease a reviewer is not.\n\
2469 4. Commit in this worktree. magi pushes to the pull request's branch \
2470 for you; do not push, merge, or close anything yourself.\n\
2471 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
2472 # Output\n\n\
2473 Say what you changed and why, and what you declined and why.",
2474 );
2475
2476 let language = &state.config.graph.language;
2477 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
2478 let _ = write!(s, "\n\nWrite all prose in {language}.");
2479 }
2480 s.push_str(&crate::prompt::github_english(language));
2482 if let Some(overlay) = state.config.prompts.overlay("fix") {
2483 let _ = write!(s, "\n\n{overlay}");
2484 }
2485 s
2486}
2487
2488async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
2491 let mut out = String::new();
2492 for (name, url) in failing.iter().take(MAX_LOGS) {
2493 let args = match (job_of(url), run_of(url)) {
2494 (Some(job), _) => vec![
2495 "run".to_owned(),
2496 "view".to_owned(),
2497 "--log-failed".to_owned(),
2498 "--job".to_owned(),
2499 job,
2500 ],
2501 (None, Some(run)) => vec![
2502 "run".to_owned(),
2503 "view".to_owned(),
2504 run,
2505 "--log-failed".to_owned(),
2506 ],
2507 (None, None) => continue,
2509 };
2510 let (ok, body) = match gh(repo, &args).await {
2511 Ok(v) => v,
2512 Err(e) => (false, format!("{e:#}")),
2513 };
2514 if !ok && body.trim().is_empty() {
2515 continue;
2516 }
2517 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
2518 }
2519 out
2520}
2521
2522fn job_of(details_url: &str) -> Option<String> {
2525 let after = details_url.split("/job/").nth(1)?;
2526 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2527 (!id.is_empty()).then_some(id)
2528}
2529
2530fn run_of(details_url: &str) -> Option<String> {
2532 let after = details_url.split("/actions/runs/").nth(1)?;
2533 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2534 (!id.is_empty()).then_some(id)
2535}
2536
2537fn stop_comment(run_id: &str, why: &str) -> String {
2541 format!(
2542 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
2543 The branch is untouched and the run is `{run_id}`. Nothing was merged."
2544 )
2545}
2546
2547async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
2552 let body = stop_comment(&state.id, why);
2553 let posted = gh(
2554 repo,
2555 &[
2556 "pr".to_owned(),
2557 "comment".to_owned(),
2558 pr.number.to_string(),
2559 "--body".to_owned(),
2560 body,
2561 ],
2562 )
2563 .await;
2564 match posted {
2565 Ok((true, _)) => {}
2566 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
2567 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
2568 }
2569 state.status = RunStatus::Blocked;
2570 state.merge = Some(MergeOutcome {
2571 mode: MergeMode::Pr,
2572 ok: false,
2573 detail: why.to_owned(),
2574 empty: false,
2575 });
2576 state.event("land", format!("stopped: {why}"));
2577 state.save()?;
2578 Ok(())
2579}
2580
2581async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
2597 let out = tokio::process::Command::new("gh")
2598 .args(args)
2599 .current_dir(cwd)
2600 .env_remove("GH_REPO")
2601 .quiet()
2602 .stdin(std::process::Stdio::null())
2603 .output()
2604 .await
2605 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
2606 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
2607 let err = String::from_utf8_lossy(&out.stderr);
2608 if body.trim().is_empty() {
2609 body = err.into_owned();
2610 } else if !err.trim().is_empty() {
2611 body.push_str(&err);
2612 }
2613 Ok((out.status.success(), body.trim().to_owned()))
2614}
2615
2616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2618enum Verdict {
2619 Pass,
2620 Fail,
2621 Pending,
2622 Unknown,
2623}
2624
2625#[derive(Debug, Deserialize)]
2626#[serde(rename_all = "camelCase")]
2627struct GhPr {
2628 #[serde(default)]
2629 url: String,
2630 #[serde(default)]
2631 number: u64,
2632 #[serde(default)]
2633 state: String,
2634 #[serde(default)]
2635 title: String,
2636 #[serde(default)]
2637 status_check_rollup: Vec<GhCheck>,
2638 #[serde(default)]
2645 merge_state_status: String,
2646 #[serde(default)]
2647 reviews: Vec<GhReview>,
2648 #[serde(default)]
2649 comments: Vec<GhComment>,
2650}
2651
2652#[derive(Debug, Deserialize)]
2657#[serde(rename_all = "camelCase")]
2658struct GhCheck {
2659 #[serde(default)]
2660 name: Option<String>,
2661 #[serde(default)]
2662 context: Option<String>,
2663 #[serde(default)]
2664 status: Option<String>,
2665 #[serde(default)]
2666 conclusion: Option<String>,
2667 #[serde(default)]
2668 state: Option<String>,
2669 #[serde(default)]
2670 details_url: Option<String>,
2671 #[serde(default)]
2672 target_url: Option<String>,
2673}
2674
2675impl GhCheck {
2676 fn label(&self) -> String {
2678 self.name
2679 .clone()
2680 .or_else(|| self.context.clone())
2681 .unwrap_or_else(|| "(unnamed check)".to_owned())
2682 }
2683
2684 fn url(&self) -> Option<&str> {
2686 self.details_url
2687 .as_deref()
2688 .or(self.target_url.as_deref())
2689 .filter(|u| !u.is_empty())
2690 }
2691
2692 fn verdict(&self) -> Verdict {
2700 if let Some(status) = self.status.as_deref() {
2701 if !status.eq_ignore_ascii_case("COMPLETED") {
2702 return Verdict::Pending;
2703 }
2704 }
2705 let outcome = self
2706 .conclusion
2707 .as_deref()
2708 .or(self.state.as_deref())
2709 .unwrap_or("");
2710 match outcome.to_ascii_uppercase().as_str() {
2711 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
2712 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
2713 | "ACTION_REQUIRED" => Verdict::Fail,
2714 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
2715 Verdict::Pending
2716 }
2717 _ => Verdict::Unknown,
2718 }
2719 }
2720}
2721
2722#[derive(Debug, Deserialize)]
2723struct GhAuthor {
2724 #[serde(default)]
2725 login: String,
2726}
2727
2728#[derive(Debug, Deserialize)]
2729struct GhReview {
2730 #[serde(default)]
2731 author: GhAuthor,
2732 #[serde(default)]
2733 body: String,
2734}
2735
2736#[derive(Debug, Deserialize)]
2737struct GhComment {
2738 #[serde(default)]
2739 author: GhAuthor,
2740 #[serde(default)]
2741 body: String,
2742}
2743
2744#[derive(Debug, Deserialize)]
2745struct GhUser {
2746 #[serde(default)]
2747 login: String,
2748}
2749
2750#[derive(Debug, Deserialize)]
2751struct GhInline {
2752 #[serde(default)]
2753 user: GhUser,
2754 #[serde(default)]
2755 path: Option<String>,
2756 #[serde(default)]
2757 line: Option<u64>,
2758 #[serde(default)]
2759 body: String,
2760}
2761
2762impl Default for GhAuthor {
2763 fn default() -> Self {
2764 Self {
2765 login: "(unknown)".to_owned(),
2766 }
2767 }
2768}
2769
2770impl Default for GhUser {
2771 fn default() -> Self {
2772 Self {
2773 login: "(unknown)".to_owned(),
2774 }
2775 }
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780 use super::*;
2781 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
2782
2783 const GREEN_OPEN: &str = r####"{
2785 "url": "https://github.com/yukimemi/magi/pull/10",
2786 "number": 10,
2787 "state": "OPEN",
2788 "mergeStateStatus": "CLEAN",
2789 "statusCheckRollup": [
2790 {
2791 "__typename": "CheckRun",
2792 "conclusion": "SKIPPED",
2793 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2794 "name": "review",
2795 "status": "COMPLETED",
2796 "workflowName": "claude-review"
2797 },
2798 {
2799 "__typename": "CheckRun",
2800 "conclusion": "SUCCESS",
2801 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2802 "name": "check (ubuntu-latest)",
2803 "status": "COMPLETED",
2804 "workflowName": "CI"
2805 },
2806 {
2807 "__typename": "CheckRun",
2808 "conclusion": "SUCCESS",
2809 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2810 "name": "rustfmt",
2811 "status": "COMPLETED",
2812 "workflowName": "CI"
2813 },
2814 {
2815 "__typename": "StatusContext",
2816 "context": "CodeRabbit",
2817 "state": "SUCCESS",
2818 "targetUrl": ""
2819 }
2820 ],
2821 "reviews": [],
2822 "comments": [
2823 {
2824 "author": {
2825 "login": "coderabbitai"
2826 },
2827 "authorAssociation": "NONE",
2828 "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"
2829 }
2830 ]
2831}"####;
2832
2833 const RED_OPEN: &str = r####"{
2835 "url": "https://github.com/yukimemi/magi/pull/9",
2836 "number": 9,
2837 "state": "OPEN",
2838 "mergeStateStatus": "UNSTABLE",
2839 "statusCheckRollup": [
2840 {
2841 "__typename": "CheckRun",
2842 "conclusion": "SUCCESS",
2843 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2844 "name": "check (ubuntu-latest)",
2845 "status": "COMPLETED",
2846 "workflowName": "CI"
2847 },
2848 {
2849 "__typename": "CheckRun",
2850 "conclusion": "SUCCESS",
2851 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2852 "name": "rustfmt",
2853 "status": "COMPLETED",
2854 "workflowName": "CI"
2855 },
2856 {
2857 "__typename": "CheckRun",
2858 "conclusion": "FAILURE",
2859 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2860 "name": "editorconfig",
2861 "status": "COMPLETED",
2862 "workflowName": "CI"
2863 },
2864 {
2865 "__typename": "StatusContext",
2866 "context": "CodeRabbit",
2867 "state": "SUCCESS",
2868 "targetUrl": ""
2869 }
2870 ],
2871 "reviews": [],
2872 "comments": [
2873 {
2874 "author": {
2875 "login": "coderabbitai"
2876 },
2877 "authorAssociation": "NONE",
2878 "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"
2879 }
2880 ]
2881}"####;
2882
2883 const PENDING_OPEN: &str = r####"{
2885 "url": "https://github.com/yukimemi/magi/pull/9",
2886 "number": 9,
2887 "state": "OPEN",
2888 "statusCheckRollup": [
2889 {
2890 "__typename": "CheckRun",
2891 "conclusion": "SUCCESS",
2892 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2893 "name": "check (ubuntu-latest)",
2894 "status": "COMPLETED",
2895 "workflowName": "CI"
2896 },
2897 {
2898 "__typename": "CheckRun",
2899 "conclusion": "SUCCESS",
2900 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2901 "name": "rustfmt",
2902 "status": "COMPLETED",
2903 "workflowName": "CI"
2904 },
2905 {
2906 "__typename": "CheckRun",
2907 "conclusion": null,
2908 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2909 "name": "editorconfig",
2910 "status": "IN_PROGRESS",
2911 "workflowName": "CI"
2912 },
2913 {
2914 "__typename": "StatusContext",
2915 "context": "CodeRabbit",
2916 "state": "SUCCESS",
2917 "targetUrl": ""
2918 }
2919 ],
2920 "reviews": [],
2921 "comments": []
2922}"####;
2923
2924 const MERGED: &str = r####"{
2926 "url": "https://github.com/yukimemi/magi/pull/16",
2927 "number": 16,
2928 "state": "MERGED",
2929 "statusCheckRollup": [
2930 {
2931 "__typename": "CheckRun",
2932 "conclusion": "SUCCESS",
2933 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2934 "name": "check (ubuntu-latest)",
2935 "status": "COMPLETED",
2936 "workflowName": "CI"
2937 },
2938 {
2939 "__typename": "CheckRun",
2940 "conclusion": "SUCCESS",
2941 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2942 "name": "review",
2943 "status": "COMPLETED",
2944 "workflowName": "claude-review"
2945 }
2946 ],
2947 "reviews": [],
2948 "comments": []
2949}"####;
2950
2951 const REVIEWED_OPEN: &str = r####"{
2953 "url": "https://github.com/yukimemi/magi/pull/12",
2954 "number": 12,
2955 "state": "OPEN",
2956 "statusCheckRollup": [
2957 {
2958 "__typename": "CheckRun",
2959 "conclusion": "SUCCESS",
2960 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2961 "name": "check (ubuntu-latest)",
2962 "status": "COMPLETED",
2963 "workflowName": "CI"
2964 },
2965 {
2966 "__typename": "CheckRun",
2967 "conclusion": "SUCCESS",
2968 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2969 "name": "review",
2970 "status": "COMPLETED",
2971 "workflowName": "claude-review"
2972 }
2973 ],
2974 "reviews": [
2975 {
2976 "author": {
2977 "login": "claude"
2978 },
2979 "state": "COMMENTED",
2980 "body": ""
2981 }
2982 ],
2983 "comments": [
2984 {
2985 "author": {
2986 "login": "coderabbitai"
2987 },
2988 "authorAssociation": "NONE",
2989 "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"
2990 },
2991 {
2992 "author": {
2993 "login": "claude"
2994 },
2995 "authorAssociation": "NONE",
2996 "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"
2997 }
2998 ]
2999}"####;
3000
3001 const INLINE: &str = r####"[
3003 {
3004 "user": {
3005 "login": "claude[bot]"
3006 },
3007 "path": "src/graph.rs",
3008 "line": 231,
3009 "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"
3010 }
3011]"####;
3012
3013 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
3015<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
3016
3017> [!IMPORTANT]
3018> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
3019>
3020> This repository does not receive automatic reviews because it has fewer than 10 stars.
3021>
3022> <details>
3023> <summary>⚙️ Run configuration</summary>
3024>
3025> **Configuration used**: defaults
3026>
3027> **Review profile**: CHILL
3028>
3029> **Plan**: Team
3030>
3031> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
3032>
3033> </details>
3034
3035<!-- end of auto-generated comment: skip review by coderabbit.ai -->
3036
3037<!-- tips_start -->
3038
3039---
3040
3041Thanks 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.
3042
3043<details>
3044<summary>❤️ Share</summary>
3045
3046- [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"####;
3047
3048 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
3050
3051---
3052### Reviewing PR #16
3053
3054- [x] Read AGENTS.md conventions
3055- [x] Review `src/daemon.rs` changes
3056- [x] Review `src/main.rs` changes (new `doctor` reporting)
3057- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
3058- [x] Check test coverage for new behavior
3059- [x] Run verification commands (blocked — see note)
3060- [x] Post findings"####;
3061
3062 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
3064
3065---
3066### Review: `magi review <branch>` — cheap-half-only graph
3067
3068Read 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.
3069
3070**Correctness**
3071
3072- 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"####;
3073
3074 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
3075 PrState {
3076 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
3077 number: 16,
3078 state: PrLifecycle::Open,
3079 checks,
3080 blocking: if matches!(checks, Checks::Red) {
3084 Blocking::Yes
3085 } else {
3086 Blocking::No
3087 },
3088 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
3089 review_comments: (0..comments)
3090 .map(|i| ReviewComment {
3091 author: "coderabbitai".to_owned(),
3092 path: Some("src/graph.rs".to_owned()),
3093 line: Some(231),
3094 body: format!("finding {i}"),
3095 })
3096 .collect(),
3097 }
3098 }
3099
3100 #[test]
3101 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
3102 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
3103 assert_eq!(state.number, 10);
3104 assert_eq!(state.state, PrLifecycle::Open);
3105 assert_eq!(state.checks, Checks::Green);
3106 assert!(state.failing.is_empty());
3107 assert!(
3108 state.review_comments.is_empty(),
3109 "the only comment is CodeRabbit's trigger notice: {:?}",
3110 state.review_comments
3111 );
3112 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
3113 }
3114
3115 #[test]
3116 fn a_failing_check_parses_as_red_and_is_named() {
3117 let state = parse_pr(RED_OPEN).expect("red fixture parses");
3118 assert_eq!(state.checks, Checks::Red);
3119 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
3120 let mut blocking = state.clone();
3127 blocking.blocking = Blocking::Yes;
3128 match decide(&blocking, 0, 4, Duration::ZERO) {
3129 Step::Fix { reason } => {
3130 assert!(reason.contains("editorconfig"), "reason: {reason}");
3131 assert!(reason.contains("failing"), "reason: {reason}");
3132 }
3133 other => panic!("expected a fix round, got {other:?}"),
3134 }
3135 }
3136
3137 #[test]
3138 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
3139 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
3140 assert_eq!(state.checks, Checks::Pending);
3141 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
3142 }
3143
3144 #[test]
3145 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
3146 let state = parse_pr(MERGED).expect("merged fixture parses");
3147 assert_eq!(state.state, PrLifecycle::Merged);
3148 assert_eq!(
3149 decide(&state, 0, 4, Duration::ZERO),
3150 Step::Done { merged: true }
3151 );
3152 }
3153
3154 #[test]
3155 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
3156 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
3157 assert_eq!(state.checks, Checks::Green);
3158 let authors: Vec<&str> = state
3159 .review_comments
3160 .iter()
3161 .map(|c| c.author.as_str())
3162 .collect();
3163 assert_eq!(
3164 authors,
3165 vec!["claude"],
3166 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
3167 );
3168 match decide(&state, 0, 4, Duration::ZERO) {
3169 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
3170 other => panic!("expected a fix round, got {other:?}"),
3171 }
3172 }
3173
3174 #[test]
3175 fn inline_review_comments_keep_their_file_and_line() {
3176 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
3177 assert_eq!(comments.len(), 1);
3178 assert_eq!(comments[0].author, "claude[bot]");
3179 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
3180 assert_eq!(comments[0].line, Some(231));
3181 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
3182 }
3183
3184 #[test]
3185 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
3186 assert!(
3187 is_noise(CODERABBIT_TRIGGER),
3188 "CodeRabbit's trigger notice declares itself not a review"
3189 );
3190 assert!(
3191 is_noise(CLAUDE_CHECKLIST),
3192 "a progress checklist asks for nothing"
3193 );
3194 assert!(
3195 !is_noise(CLAUDE_FINDING),
3196 "a review that names a bug is input, not noise"
3197 );
3198
3199 let mut clean = pr(Checks::Green, &[], 0);
3200 clean.review_comments.push(ReviewComment {
3201 author: "coderabbitai".to_owned(),
3202 path: None,
3203 line: None,
3204 body: CODERABBIT_TRIGGER.to_owned(),
3205 });
3206 clean.review_comments.retain(|c| !is_noise(&c.body));
3207 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
3208
3209 let mut found = pr(Checks::Green, &[], 0);
3210 found.review_comments.push(ReviewComment {
3211 author: "claude".to_owned(),
3212 path: None,
3213 line: None,
3214 body: CLAUDE_FINDING.to_owned(),
3215 });
3216 found.review_comments.retain(|c| !is_noise(&c.body));
3217 assert!(matches!(
3218 decide(&found, 0, 4, Duration::ZERO),
3219 Step::Fix { .. }
3220 ));
3221 }
3222
3223 #[test]
3224 fn the_policy_table_holds_for_every_combination_that_matters() {
3225 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
3226 (
3227 "pending checks are waited for, even on the last round",
3228 pr(Checks::Pending, &[], 0),
3229 4,
3230 4,
3231 Duration::ZERO,
3232 Step::Wait,
3233 ),
3234 (
3235 "red checks are fixed",
3236 pr(Checks::Red, &["editorconfig"], 0),
3237 0,
3238 4,
3239 Duration::ZERO,
3240 Step::Fix {
3241 reason: "1 check(s) failing: editorconfig".to_owned(),
3242 },
3243 ),
3244 (
3245 "green with comments is fixed, not merged",
3246 pr(Checks::Green, &[], 2),
3247 1,
3248 4,
3249 Duration::ZERO,
3250 Step::Fix {
3251 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
3252 .to_owned(),
3253 },
3254 ),
3255 (
3256 "green and clean merges",
3257 pr(Checks::Green, &[], 0),
3258 3,
3259 4,
3260 Duration::ZERO,
3261 Step::Merge,
3262 ),
3263 (
3264 "an unreadable rollup is waited on while the grace lasts",
3265 pr(Checks::Unknown, &[], 0),
3266 0,
3267 4,
3268 Duration::ZERO,
3269 Step::Wait,
3270 ),
3271 (
3272 "an unreadable rollup is never merged once the grace is spent",
3273 pr(Checks::Unknown, &[], 0),
3274 0,
3275 4,
3276 CHECKS_GRACE,
3277 Step::GiveUp {
3278 reason: "no check status is readable on the pull request after 3 minute(s); \
3279 refusing to merge on a guess"
3280 .to_owned(),
3281 },
3282 ),
3283 ];
3284 for (what, state, round, budget, waited, want) in cases {
3285 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
3286 }
3287 }
3288
3289 #[test]
3290 fn the_forge_verdict_survives_the_round_trip_from_gh() {
3291 let green = parse_pr(GREEN_OPEN).expect("parse");
3295 assert_eq!(green.blocking, Blocking::No);
3296 let red = parse_pr(RED_OPEN).expect("parse");
3297 assert_eq!(
3298 red.blocking,
3299 Blocking::No,
3300 "`UNSTABLE` is mergeable: the red check is one nobody requires"
3301 );
3302 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
3303 let quiet =
3305 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
3306 assert_eq!(quiet.blocking, Blocking::Unsaid);
3307 }
3308
3309 #[test]
3310 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
3311 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
3317 nonblocking.blocking = Blocking::No;
3318 assert_eq!(
3319 decide(&nonblocking, 0, 4, Duration::ZERO),
3320 Step::Merge,
3321 "the forge says nothing is in the way, so nothing is"
3322 );
3323
3324 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
3326 blocking.blocking = Blocking::Yes;
3327 assert!(matches!(
3328 decide(&blocking, 0, 4, Duration::ZERO),
3329 Step::Fix { .. }
3330 ));
3331
3332 let mut commented = pr(Checks::Red, &["coverage"], 1);
3335 commented.blocking = Blocking::No;
3336 assert!(matches!(
3337 decide(&commented, 0, 4, Duration::ZERO),
3338 Step::Fix { .. }
3339 ));
3340
3341 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
3343 unsaid.blocking = Blocking::Unsaid;
3344 assert!(matches!(
3345 decide(&unsaid, 0, 4, Duration::ZERO),
3346 Step::Fix { .. }
3347 ));
3348 }
3349
3350 #[test]
3351 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
3352 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
3353 red.blocking = Blocking::No;
3354 assert_eq!(
3355 decide(&red, 0, 4, Duration::ZERO),
3356 Step::Merge,
3357 "announcing must not change the decision"
3358 );
3359 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
3360 assert!(said.contains("yukimemi/magi"), "{said}");
3361 assert!(said.contains("#16"), "{said}");
3362 assert!(
3363 said.contains("https://github.com/yukimemi/magi/pull/16"),
3364 "{said}"
3365 );
3366 assert!(
3367 said.contains("test (windows-latest)") && said.contains("coverage"),
3368 "{said}"
3369 );
3370
3371 let green = pr(Checks::Green, &["stale"], 0);
3373 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
3374 }
3375
3376 #[test]
3377 fn the_repo_label_comes_from_the_pull_request_url() {
3378 let p = Path::new("/tmp/checkout");
3379 assert_eq!(
3380 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
3381 "yukimemi/magi"
3382 );
3383 assert_eq!(repo_label(p, "not a url"), "checkout");
3384 }
3385
3386 #[test]
3387 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
3388 let mut conflicted = pr(Checks::Green, &[], 0);
3393 conflicted.blocking = Blocking::Conflict;
3394 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
3395
3396 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
3400 red.blocking = Blocking::Conflict;
3401 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
3402
3403 let mut merged = pr(Checks::Red, &[], 0);
3405 merged.blocking = Blocking::Conflict;
3406 merged.state = PrLifecycle::Merged;
3407 assert_eq!(
3408 decide(&merged, 0, 4, Duration::ZERO),
3409 Step::Done { merged: true }
3410 );
3411 }
3412
3413 #[test]
3414 fn the_forge_verdict_is_read_off_merge_state_status() {
3415 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
3418 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
3419 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
3420 }
3421 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
3422 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
3423 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
3424 for quiet in ["", "UNKNOWN"] {
3427 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
3428 assert!(Blocking::of(quiet).stops_a_merge());
3429 }
3430 }
3431
3432 #[test]
3433 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
3434 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
3435 let jj = "could not determine current branch: failed to run git: not on any branch";
3437
3438 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
3439 .expect("the forge says merged, so it merged");
3440 assert!(landed.ok);
3441 assert!(
3442 landed.detail.contains("but the pull request is merged"),
3443 "the record must not read as a clean success: {}",
3444 landed.detail
3445 );
3446 assert!(
3447 landed.detail.contains("not on any branch"),
3448 "and it must keep what the command actually said: {}",
3449 landed.detail
3450 );
3451
3452 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
3454 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
3455 assert!(merged_after_all(&argv, jj, None).is_none());
3457 }
3458
3459 #[test]
3460 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
3461 let mut state = pr(Checks::Red, &["editorconfig"], 3);
3462 state.state = PrLifecycle::Closed;
3463 assert_eq!(
3464 decide(&state, 0, 4, Duration::ZERO),
3465 Step::Done { merged: false },
3466 "a human closing the pull request ends the loop, whatever CI says"
3467 );
3468 }
3469
3470 #[test]
3471 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
3472 let red = decide(
3473 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
3474 4,
3475 4,
3476 Duration::ZERO,
3477 );
3478 match red {
3479 Step::GiveUp { reason } => {
3480 assert!(reason.contains("editorconfig"), "reason: {reason}");
3481 assert!(reason.contains("test (macos)"), "reason: {reason}");
3482 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
3483 }
3484 other => panic!("expected a give-up, got {other:?}"),
3485 }
3486
3487 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
3488 match commented {
3489 Step::GiveUp { reason } => {
3490 assert!(reason.contains("unresolved"), "reason: {reason}");
3491 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
3492 }
3493 other => panic!("expected a give-up, got {other:?}"),
3494 }
3495 }
3496
3497 #[test]
3498 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
3499 let candidate_commit = "magi: candidate A (uncommitted work)";
3500 let subject = merge_subject(candidate_commit, "add retries to the uploader");
3501 let argv = merge_argv(16, &subject);
3502
3503 assert!(argv.contains(&"--squash".to_owned()));
3504 assert!(argv.contains(&"--delete-branch".to_owned()));
3505 assert!(argv.contains(&"--subject".to_owned()));
3506 assert_eq!(
3507 argv.last().map(String::as_str),
3508 Some("add retries to the uploader"),
3509 "the subject must not be the candidate commit message"
3510 );
3511 assert_ne!(subject, candidate_commit);
3512 }
3513
3514 #[test]
3515 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
3516 assert_eq!(
3517 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
3518 "feat: a queue, an unattended loop, and a phone UI"
3519 );
3520 assert_eq!(
3521 merge_subject("", "# port the retry logic\n\ndetails"),
3522 "port the retry logic",
3523 "an empty title falls back to the task's first line, heading marks stripped"
3524 );
3525 }
3526
3527 #[test]
3528 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
3529 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
3530 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
3531 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
3532 assert_eq!(job_of("https://coderabbit.ai/status"), None);
3533 assert_eq!(run_of(""), None);
3534 }
3535
3536 #[test]
3537 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
3538 let mut out = Vec::new();
3539 push_if_outstanding(
3540 &mut out,
3541 ReviewComment {
3542 author: "yukimemi".to_owned(),
3543 path: None,
3544 line: None,
3545 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
3546 },
3547 );
3548 assert!(out.is_empty());
3549 }
3550
3551 fn run_state() -> RunState {
3555 RunState::new(
3556 std::path::PathBuf::from("/repo/magi"),
3557 "main".to_owned(),
3558 "abcdef1234".to_owned(),
3559 "add retries to the uploader".to_owned(),
3560 crate::config::Config::default(),
3561 )
3562 }
3563
3564 fn green_pr() -> PrState {
3565 PrState {
3566 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
3567 number: 42,
3568 state: PrLifecycle::Open,
3569 checks: Checks::Green,
3570 blocking: Blocking::No,
3572 failing: Vec::new(),
3573 review_comments: vec![ReviewComment {
3574 author: "coderabbitai".to_owned(),
3575 path: Some("src/land.rs".to_owned()),
3576 line: Some(212),
3577 body: "this branch never checks the exit code".to_owned(),
3578 }],
3579 }
3580 }
3581
3582 #[test]
3583 fn github_facing_land_text_is_english_whatever_the_language() {
3584 let mut state = run_state();
3585 state.config.graph.language = "ja".to_owned();
3586 let comment = stop_comment(&state.id, "checks are still red");
3587 assert!(comment.is_ascii(), "{comment}");
3588 assert!(comment.starts_with(MARKER));
3589
3590 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3591 let ja_at = p.find("Write all prose in ja").unwrap();
3592 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
3593 assert!(ja_at < rule_at, "{p}");
3594 assert!(p.contains("stays in Japanese"), "{p}");
3595
3596 state.config.graph.language = "en".to_owned();
3597 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3598 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
3599 assert!(!p.contains("does not apply"), "{p}");
3600 }
3601
3602 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
3603
3604 fn panel() -> String {
3605 approval_panel(
3606 &run_state(),
3607 &green_pr(),
3608 NUMSTAT,
3609 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
3610 &[
3611 "land: ask before merging".to_owned(),
3612 "land: colour the diff".to_owned(),
3613 ],
3614 "feat: merge approval from the phone",
3615 )
3616 }
3617
3618 #[test]
3619 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
3620 let html = panel();
3621 for needle in [
3622 "42",
3623 "main",
3624 "src/land.rs",
3625 "src/web.rs",
3626 "assets/logo.png",
3627 "feat: merge approval from the phone",
3628 "land: ask before merging",
3629 "land: colour the diff",
3630 "coderabbitai",
3631 "this branch never checks the exit code",
3632 "green",
3633 ] {
3634 assert!(html.contains(needle), "the panel must state `{needle}`");
3635 }
3636 }
3637
3638 fn winning_candidate(summary: &str) -> Candidate {
3641 Candidate {
3642 index: 0,
3643 label: 'A',
3644 agent: "opus".to_owned(),
3645 branch: "magi/x/A".to_owned(),
3646 worktree: PathBuf::from("/wt/A"),
3647 summary: summary.to_owned(),
3648 stat: String::new(),
3649 files: 1,
3650 commits: 1,
3651 empty: false,
3652 failed: None,
3653 verified_noop: None,
3654 duration_ms: 0,
3655 folded: false,
3656 }
3657 }
3658
3659 fn uncontested_tally() -> Tally {
3660 Tally {
3661 first_choice: BTreeMap::from([('A', 1)]),
3662 borda: BTreeMap::new(),
3663 winner: 'A',
3664 rankings: 1,
3665 unanimous_initial: true,
3666 deliberated: false,
3667 changed_votes: 0,
3668 unanimous_final: true,
3669 tie_break: None,
3670 judges: 1,
3671 present: 1,
3672 quorum: 1,
3673 met_quorum: true,
3674 uncontested: None,
3675 }
3676 }
3677
3678 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
3679 ReviewRecord {
3680 attempts: 0,
3681 reviewer,
3682 agent: agent.to_owned(),
3683 summary: summary.to_owned(),
3684 findings: Vec::new(),
3685 vote: None,
3686 failed: None,
3687 duration_ms: 0,
3688 }
3689 }
3690
3691 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
3692 let answered = reviews.len();
3693 ReviewRound {
3694 round,
3695 head: "abc1234".to_owned(),
3696 verified_head: None,
3697 verified_at: None,
3698 reviews,
3699 e2e: Vec::new(),
3700 verify_retried: false,
3701 e2e_deferred: false,
3702 e2e_defer_reason: None,
3703 fix: None,
3704 blocking: 0,
3705 answered,
3706 expected: answered,
3707 clean: true,
3708 progressed: false,
3709 vote_split: false,
3710 reconsideration: Vec::new(),
3711 verdict: None,
3712 }
3713 }
3714
3715 #[test]
3716 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
3717 let en = panel();
3718 assert!(en.contains("Task"), "{en}");
3719 assert!(en.contains("add retries to the uploader"), "{en}");
3720
3721 let mut state = run_state();
3722 state.config.graph.language = "ja".to_owned();
3723 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3724 assert!(ja.contains("タスク"), "{ja}");
3725 assert!(
3726 ja.contains("add retries to the uploader"),
3727 "the task itself is not translated: {ja}"
3728 );
3729 }
3730
3731 #[test]
3732 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
3733 let html = panel();
3737 assert!(!html.contains("What changed"), "{html}");
3738 assert!(!html.contains("Review verdict"), "{html}");
3739 }
3740
3741 #[test]
3742 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
3743 let mut state = run_state();
3744 state.candidates = vec![winning_candidate("")];
3745 state.tally = Some(uncontested_tally());
3746 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3747 assert!(
3748 !html.contains("What changed"),
3749 "an empty summary must not render an empty box: {html}"
3750 );
3751 }
3752
3753 #[test]
3754 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
3755 let mut state = run_state();
3756 state.candidates = vec![winning_candidate(
3757 "Added a retry loop around the uploader PUT call.",
3758 )];
3759 state.tally = Some(uncontested_tally());
3760 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3761 assert!(en.contains("What changed"), "{en}");
3762 assert!(
3763 en.contains("Added a retry loop around the uploader PUT call."),
3764 "{en}"
3765 );
3766
3767 state.config.graph.language = "ja".to_owned();
3768 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3769 assert!(ja.contains("変更内容"), "{ja}");
3770 assert!(
3771 ja.contains("Added a retry loop around the uploader PUT call."),
3772 "{ja}"
3773 );
3774 }
3775
3776 #[test]
3777 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
3778 let mut state = run_state();
3779 state.reviews = vec![
3780 review_round(
3781 1,
3782 vec![review_record(1, "alpha", "found a race, sent back")],
3783 ),
3784 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
3785 ];
3786 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3787 assert!(en.contains("Review verdict"), "{en}");
3788 assert!(en.contains("race is fixed, clean"), "{en}");
3789 assert!(
3790 !en.contains("found a race, sent back"),
3791 "only the round that actually cleared the merge should show: {en}"
3792 );
3793
3794 state.config.graph.language = "ja".to_owned();
3795 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3796 assert!(ja.contains("レビューの結論"), "{ja}");
3797 assert!(ja.contains("レビュアー"), "{ja}");
3798 assert!(ja.contains("race is fixed, clean"), "{ja}");
3799 }
3800
3801 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
3807 ReviewRecord {
3808 attempts: 0,
3809 reviewer,
3810 agent: agent.to_owned(),
3811 summary: String::new(),
3812 findings: Vec::new(),
3813 vote: None,
3814 failed: Some(reason.to_owned()),
3815 duration_ms: 0,
3816 }
3817 }
3818
3819 #[test]
3820 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
3821 let mut state = run_state();
3822 state.reviews = vec![review_round(
3823 1,
3824 vec![
3825 review_record(1, "alpha", "clean, nothing to add"),
3826 unanswered_review_record(2, "beta", "timed out"),
3827 ],
3828 )];
3829 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3830 assert!(en.contains("clean, nothing to add"), "{en}");
3831 assert!(
3832 en.contains("produced no answer: timed out"),
3833 "a seat that never answered must say so, not render a blank box: {en}"
3834 );
3835 assert!(
3836 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
3837 "no reviewer box may be left empty: {en}"
3838 );
3839
3840 state.config.graph.language = "ja".to_owned();
3841 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3842 assert!(ja.contains("回答なし: timed out"), "{ja}");
3843 }
3844
3845 #[test]
3846 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
3847 let html = panel();
3848 assert!(!html.contains("<script"), "no script survives the csp");
3849 assert!(!html.contains("<form"), "form-action is 'none'");
3850 let pr = green_pr();
3851 assert_eq!(
3852 html.matches("http").count(),
3853 html.matches(pr.url.as_str()).count(),
3854 "the only http url in the panel is the pull request's own link"
3855 );
3856 }
3857
3858 #[test]
3859 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
3860 let html = panel();
3861 assert!(
3862 html.contains(">+</span>"),
3863 "an added line carries a `+` in the gutter, not only a background"
3864 );
3865 assert!(
3866 html.contains(">-</span>"),
3867 "a removed line carries a `-` in the gutter, not only a background"
3868 );
3869 assert!(
3870 html.contains(">new line</span>"),
3871 "the marker is moved to the gutter, so the body is printed once without it"
3872 );
3873 }
3874
3875 #[test]
3876 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
3877 let total = DIFF_MAX_LINES + 100;
3878 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
3879 let html = approval_panel(
3880 &run_state(),
3881 &green_pr(),
3882 NUMSTAT,
3883 &diff,
3884 &[],
3885 "feat: something long",
3886 );
3887 assert!(
3888 html.contains(&format!("100 of {total} diff lines omitted")),
3889 "the note must say exactly how much was cut"
3890 );
3891 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
3892 assert!(
3893 !html.contains(&format!("line {DIFF_MAX_LINES}")),
3894 "nothing past the threshold is rendered"
3895 );
3896 assert!(
3897 html.contains("/repo/magi"),
3898 "the note says where the rest is"
3899 );
3900 }
3901
3902 #[test]
3903 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
3904 let html = approval_panel(
3905 &run_state(),
3906 &green_pr(),
3907 "1\t2\tsrc/<b>&\"x\"'.rs",
3908 "",
3909 &[],
3910 "subject",
3911 );
3912 assert!(html.contains("src/<b>&"x"'.rs"));
3913 assert!(
3914 !html.contains("<b>"),
3915 "an agent-influenced path must never become markup"
3916 );
3917 }
3918
3919 #[tokio::test]
3920 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
3921 let a = std::path::PathBuf::from("/repo/a");
3922 let b = std::path::PathBuf::from("/repo/b");
3923
3924 let held = repo_merge_lock(&a).lock_owned().await;
3925
3926 assert!(
3929 repo_merge_lock(&a).try_lock().is_err(),
3930 "a second merge into the same repository must not proceed concurrently"
3931 );
3932
3933 assert!(
3937 repo_merge_lock(&b).try_lock().is_ok(),
3938 "a different repository's merge lock must be independent"
3939 );
3940
3941 drop(held);
3942 assert!(
3943 repo_merge_lock(&a).try_lock().is_ok(),
3944 "the lock is released once the holder is done"
3945 );
3946 }
3947
3948 #[test]
3949 fn only_the_merge_choice_merges_and_silence_holds() {
3950 let table = [
3951 (None, Approval::Hold),
3952 (Some("merge"), Approval::Merge),
3953 (Some(" merge\n"), Approval::Merge),
3954 (Some("hold"), Approval::Hold),
3955 (Some(""), Approval::Hold),
3956 (Some("yes"), Approval::Hold),
3957 ];
3958 for (answer, want) in table {
3959 assert_eq!(
3960 approval(answer),
3961 want,
3962 "answer {answer:?} must resolve to {want:?}"
3963 );
3964 }
3965 }
3966
3967 #[tokio::test]
3968 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
3969 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3970 let mut state = run_state();
3971 state.config.graph.land_approval = true;
3972 let pr = green_pr();
3973
3974 let gate = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3975 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
3976 assert!(
3977 !state.parked,
3978 "approval_gate itself never sets `parked`; only its caller does"
3979 );
3980
3981 let store = ask::Questions::open();
3982 let filed: Vec<_> = store
3983 .list()
3984 .into_iter()
3985 .filter(|q| q.run == state.id)
3986 .collect();
3987 assert_eq!(filed.len(), 1, "exactly one question is filed");
3988 assert_eq!(filed[0].node, APPROVAL_NODE);
3989 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
3990 assert!(filed[0].status.open());
3991
3992 let again = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3996 assert_eq!(again, ApprovalGate::Pending);
3997 let still_one = store
3998 .list()
3999 .into_iter()
4000 .filter(|q| q.run == state.id)
4001 .count();
4002 assert_eq!(
4003 still_one, 1,
4004 "asking twice must not double-file the question"
4005 );
4006 }
4007
4008 #[tokio::test]
4009 async fn approving_the_existing_question_is_read_back_as_approved() {
4010 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4011 let mut state = run_state();
4012 state.config.graph.land_approval = true;
4013 let pr = green_pr();
4014 assert_eq!(
4015 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
4016 ApprovalGate::Pending
4017 );
4018
4019 let store = ask::Questions::open();
4020 let mut q = store
4021 .list()
4022 .into_iter()
4023 .find(|q| q.run == state.id)
4024 .expect("filed above");
4025 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
4026 store.put(&mut q).unwrap();
4027
4028 assert_eq!(
4029 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
4030 ApprovalGate::Approved
4031 );
4032 }
4033
4034 #[tokio::test]
4035 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
4036 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4037 let store = ask::Questions::open();
4038
4039 let mut held_state = run_state();
4040 held_state.config.graph.land_approval = true;
4041 let pr = green_pr();
4042 approval_gate(&mut held_state, &pr, "feat: x")
4043 .await
4044 .unwrap();
4045 let mut q = store
4046 .list()
4047 .into_iter()
4048 .find(|q| q.run == held_state.id)
4049 .expect("filed above");
4050 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
4051 store.put(&mut q).unwrap();
4052 assert_eq!(
4053 approval_gate(&mut held_state, &pr, "feat: x")
4054 .await
4055 .unwrap(),
4056 ApprovalGate::Held
4057 );
4058
4059 let mut abandoned_state = run_state();
4060 abandoned_state.config.graph.land_approval = true;
4061 approval_gate(&mut abandoned_state, &pr, "feat: x")
4062 .await
4063 .unwrap();
4064 let mut q = store
4065 .list()
4066 .into_iter()
4067 .find(|q| q.run == abandoned_state.id)
4068 .expect("filed above");
4069 q.abandon("no answer within the timeout");
4070 store.put(&mut q).unwrap();
4071 assert_eq!(
4072 approval_gate(&mut abandoned_state, &pr, "feat: x")
4073 .await
4074 .unwrap(),
4075 ApprovalGate::Held,
4076 "silence must never merge"
4077 );
4078 }
4079
4080 #[test]
4081 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
4082 let rows = parse_numstat(NUMSTAT);
4083 assert_eq!(
4084 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
4085 ["src/web.rs", "src/land.rs", "assets/logo.png"]
4086 );
4087 assert_eq!(rows[2].added, None, "a binary file has no line counts");
4088 }
4089 #[test]
4090 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
4091 let mut state = run_state();
4095 state.config.graph.language = "ja".to_owned();
4096 let pr = green_pr();
4097 let commits = ["c1".to_owned()];
4098
4099 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4100 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
4101 assert!(ja.contains("squash されるコミット"), "{ja}");
4102 assert!(ja.contains("レビューコメント"), "{ja}");
4103 assert!(ja.contains("差分"), "{ja}");
4104 assert!(
4105 !ja.contains("Commits being squashed"),
4106 "no English left over"
4107 );
4108
4109 let w = words("ja");
4110 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
4111 assert!(
4112 w.approval_detail("http://x/1", "main", "feat: x")
4113 .contains("パネル")
4114 );
4115
4116 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
4118 assert!(ja.contains("feat: x"), "nor is the merge subject");
4119
4120 state.config.graph.language = "en".to_owned();
4123 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4124 assert!(en.contains("Commits being squashed"), "{en}");
4125 assert_eq!(words("Klingon").html_lang, "en");
4126 }
4127
4128 #[test]
4132 fn pick_open_pr_classifies_by_count_and_base() {
4133 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
4134 assert_eq!(
4135 pick_open_pr(one, "main").unwrap(),
4136 OpenPr::One {
4137 url: "https://x/pull/58".into(),
4138 title: "t".into()
4139 }
4140 );
4141 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
4142 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
4143 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
4144 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
4145 assert_eq!(
4146 pick_open_pr(two, "main").unwrap(),
4147 OpenPr::Many(vec!["u1".into(), "u2".into()])
4148 );
4149 assert!(pick_open_pr("not json", "main").is_err());
4150 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
4152 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
4153 }
4154
4155 #[test]
4156 fn pick_merged_pr_picks_the_unique_match() {
4157 let json = r#"[
4158 {"url": "https://github.com/o/r/pull/42", "number": 42,
4159 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
4160 ]"#;
4161 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4162 let found = pick_merged_pr(json, "main", created_at)
4163 .expect("valid json")
4164 .expect("one unambiguous match");
4165 assert_eq!(found.url, "https://github.com/o/r/pull/42");
4166 assert_eq!(found.number, 42);
4167 }
4168
4169 #[test]
4173 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
4174 let json = r#"[
4175 {"url": "https://github.com/o/r/pull/42", "number": 42,
4176 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
4177 {"url": "https://github.com/o/r/pull/43", "number": 43,
4178 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
4179 ]"#;
4180 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4181 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4182 }
4183
4184 #[test]
4188 fn pick_merged_pr_ignores_a_different_base_branch() {
4189 let json = r#"[
4190 {"url": "https://github.com/o/r/pull/42", "number": 42,
4191 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
4192 ]"#;
4193 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4194 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4195 }
4196
4197 #[test]
4200 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
4201 let json = r#"[
4202 {"url": "https://github.com/o/r/pull/42", "number": 42,
4203 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
4204 ]"#;
4205 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4206 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4207 }
4208
4209 #[test]
4210 fn slug_of_pr_url_reads_host_owner_and_repo() {
4211 assert_eq!(
4212 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
4213 Some("github.com/yukimemi/shun")
4214 );
4215 }
4216
4217 #[test]
4218 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
4219 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
4220 assert_eq!(slug_of_pr_url("not a url at all"), None);
4221 assert_eq!(slug_of_pr_url("https://github.com"), None);
4222 }
4223
4224 #[test]
4225 fn slug_of_repo_url_reads_host_owner_and_repo() {
4226 assert_eq!(
4227 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
4228 Some("github.com/yukimemi/magi")
4229 );
4230 assert_eq!(slug_of_repo_url("https://github.com"), None);
4231 }
4232
4233 #[test]
4234 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
4235 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
4236 .expect("same repo, different case");
4237 }
4238
4239 #[test]
4244 fn ensure_same_repo_refuses_a_different_repo() {
4245 let err =
4246 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
4247 let msg = format!("{err:#}");
4248 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4249 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
4250 }
4251
4252 #[test]
4257 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
4258 let err = ensure_same_repo(
4259 "github.com/yukimemi/magi",
4260 "github.example.com/yukimemi/magi",
4261 )
4262 .unwrap_err();
4263 let msg = format!("{err:#}");
4264 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4265 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
4266 }
4267
4268 #[tokio::test]
4273 async fn find_external_merge_returns_none_without_a_winner() {
4274 let state = RunState::new(
4275 PathBuf::from("/no/such/repo"),
4276 "main".to_owned(),
4277 "0000000000000000000000000000000000000000".to_owned(),
4278 "irrelevant".to_owned(),
4279 crate::config::Config::default(),
4280 );
4281 assert_eq!(find_external_merge(&state).await.unwrap(), None);
4282 }
4283}