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 if crate::lang::is_japanese(language) {
725 &JA
726 } else {
727 &EN
728 }
729}
730
731pub fn approval_panel(
743 state: &RunState,
744 pr: &PrState,
745 diffstat: &str,
746 diff: &str,
747 commits: &[String],
748 subject: &str,
749) -> String {
750 let rows = parse_numstat(diffstat);
751 let w = words(&state.config.graph.language);
752 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
753
754 let _ = writeln!(
755 h,
756 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
757 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
758 w.html_lang
759 );
760 let _ = writeln!(
761 h,
762 "<title>merge #{} — {}</title>\n</head>",
763 pr.number,
764 esc(subject)
765 );
766 h.push_str(
767 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
768 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
769 word-break:break-word\">\n",
770 );
771
772 let _ = writeln!(
774 h,
775 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
776 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
777 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
778 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
779 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
780 pr.number,
781 esc(&state.base_branch),
782 esc(subject),
783 esc(&state.id),
784 esc(&pr.url),
785 esc(&pr.url),
786 );
787
788 let _ = writeln!(
791 h,
792 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
793 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
794 w.task,
795 esc(&state.instruction)
796 );
797
798 if let Some(summary) = state
800 .winner()
801 .map(|c| c.summary.as_str())
802 .filter(|s| !s.is_empty())
803 {
804 let _ = writeln!(
805 h,
806 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
807 <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
808 w.what_changed,
809 esc(summary)
810 );
811 }
812
813 if let Some(round) = state.reviews.last() {
816 let _ = writeln!(
817 h,
818 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
819 w.review_verdict
820 );
821 for r in &round.reviews {
822 let body = match &r.failed {
829 Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
830 None => esc(&r.summary),
831 };
832 let _ = writeln!(
833 h,
834 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
835 border-radius:6px\">\
836 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
837 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
838 w.reviewer,
839 r.reviewer,
840 esc(&r.agent),
841 body,
842 );
843 }
844 }
845
846 let _ = writeln!(
847 h,
848 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
849 w.checks,
850 esc(pr.checks.as_str())
851 );
852 if pr.failing.is_empty() {
853 let _ = writeln!(
854 h,
855 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
856 w.nothing_failing
857 );
858 } else {
859 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
860 for f in &pr.failing {
861 let _ = writeln!(h, "<li>{}</li>", esc(f));
862 }
863 h.push_str("</ul>\n");
864 }
865
866 let _ = writeln!(
869 h,
870 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
871 rows.len(),
872 w.files_changed
873 );
874 h.push_str(
875 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
876 <thead><tr>\
877 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
878 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
879 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
880 </th></tr></thead>\n<tbody>\n",
881 );
882 let mut total_added = 0u64;
883 let mut total_removed = 0u64;
884 for r in &rows {
885 total_added += r.added.unwrap_or(0);
886 total_removed += r.removed.unwrap_or(0);
887 let cell = |n: Option<u64>| match n {
888 Some(n) => n.to_string(),
889 None => "bin".to_owned(),
890 };
891 let _ = writeln!(
892 h,
893 "<tr>\
894 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
895 font-family:ui-monospace,monospace\">{}</td>\
896 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
897 color:#0a3622\">{}</td>\
898 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
899 color:#5c1a17\">{}</td></tr>",
900 esc(&r.path),
901 cell(r.added),
902 cell(r.removed),
903 );
904 }
905 let _ = writeln!(
906 h,
907 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
908 <td style=\"padding:4px 2px\">total</td>\
909 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
910 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
911 </tr></tfoot>\n</table>"
912 );
913
914 let _ = writeln!(
916 h,
917 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
918 w.commits
919 );
920 if commits.is_empty() {
921 h.push_str(&format!(
922 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
923 w.no_commits
924 ));
925 } else {
926 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
927 for c in commits {
928 let _ = writeln!(h, "<li>{}</li>", esc(c));
929 }
930 h.push_str("</ol>\n");
931 }
932 let _ = writeln!(
933 h,
934 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
935 w.lands_as,
936 esc(subject),
937 w.lands_as_tail()
938 );
939
940 let _ = writeln!(
942 h,
943 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
944 w.comments
945 );
946 if pr.review_comments.is_empty() {
947 h.push_str(&format!(
948 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
949 w.no_comments
950 ));
951 } else {
952 for c in &pr.review_comments {
953 let anchor = match (&c.path, c.line) {
954 (Some(p), Some(l)) => format!("{p}:{l}"),
955 (Some(p), None) => p.clone(),
956 _ => "pull request thread".to_owned(),
957 };
958 let _ = writeln!(
959 h,
960 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
961 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
962 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
963 esc(&c.author),
964 esc(&anchor),
965 esc(&tail(&c.body, 800)),
966 );
967 }
968 }
969
970 let total = diff.lines().count();
972 let shown = total.min(DIFF_MAX_LINES);
973 let _ = writeln!(
974 h,
975 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
976 w.diff
977 );
978 h.push_str(
979 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
980 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
981 );
982 for line in diff.lines().take(shown) {
983 let (gutter, style, body) = diff_row(line);
984 let _ = writeln!(
985 h,
986 "<div style=\"display:flex;{style}\">\
987 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
988 border-right:1px solid #d0d7de\">{gutter}</span>\
989 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
990 esc(body),
991 );
992 }
993 h.push_str("</div>\n");
994 if total > shown {
995 let omitted = total - shown;
996 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
997 let where_ = state.winner().map_or_else(
998 || state.repo.display().to_string(),
999 |w| w.worktree.display().to_string(),
1000 );
1001 let _ = writeln!(
1002 h,
1003 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1004 font-size:13px\">{}: {}</p>",
1005 w.truncated,
1006 w.truncated_note(
1007 omitted,
1008 total,
1009 shown,
1010 &esc(&where_),
1011 &esc(&state.base_branch),
1012 &esc(head),
1013 ),
1014 );
1015 }
1016
1017 h.push_str("</body>\n</html>\n");
1018 h
1019}
1020
1021async fn approval_gate(state: &mut RunState, pr: &PrState, subject: &str) -> Result<ApprovalGate> {
1041 let store = ask::Questions::open();
1042 let existing = store
1043 .list()
1044 .into_iter()
1045 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
1046 .max_by(|a, b| a.id.cmp(&b.id));
1047
1048 let q = match existing {
1049 Some(q) => q,
1050 None => {
1051 let (worktree, head) = match state.winner() {
1052 Some(w) => (w.worktree.clone(), w.branch.clone()),
1053 None => (state.repo.clone(), "HEAD".to_owned()),
1054 };
1055 let base = state.base_branch.clone();
1056 let range = format!("{base}...{head}");
1057 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1061 .await
1062 .map(|o| o.stdout)
1063 .unwrap_or_default();
1064 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1065 let commits: Vec<String> = git::git_raw(
1066 &worktree,
1067 &[
1068 "log",
1069 "--reverse",
1070 "--format=%s",
1071 &format!("{base}..{head}"),
1072 ],
1073 )
1074 .await
1075 .map(|o| o.stdout)
1076 .unwrap_or_default()
1077 .lines()
1078 .filter(|l| !l.trim().is_empty())
1079 .map(str::to_owned)
1080 .collect();
1081
1082 let w = words(&state.config.graph.language);
1083 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1084 let mut fresh = ask::Question::new(
1085 state.id.clone(),
1086 APPROVAL_NODE.to_owned(),
1087 "land".to_owned(),
1088 w.approval_summary(pr.number, subject),
1089 w.approval_detail(&pr.url, &base, subject),
1090 vec![APPROVE.to_owned(), HOLD.to_owned()],
1091 );
1092 store
1093 .put_panel(&mut fresh, &html, &[])
1094 .context("write the merge approval panel")?;
1095 store
1096 .put(&mut fresh)
1097 .context("file the merge approval question")?;
1098 state.event(
1099 "land",
1100 format!("asking for merge approval ({})", fresh.short()),
1101 );
1102 state.save()?;
1103 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1104 tracing::warn!(
1108 "could not notify about merge approval question {}: {e:#} - \
1109 the web UI is the only surface for it now",
1110 fresh.short()
1111 );
1112 }
1113 fresh
1114 }
1115 };
1116
1117 Ok(match q.status {
1118 ask::QuestionStatus::Open => ApprovalGate::Pending,
1119 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1123 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1127 Approval::Merge => ApprovalGate::Approved,
1128 Approval::Hold => ApprovalGate::Held,
1129 },
1130 })
1131}
1132
1133pub fn parse_pr(json: &str) -> Result<PrState> {
1136 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1137 let state = match raw.state.to_ascii_uppercase().as_str() {
1138 "OPEN" => PrLifecycle::Open,
1139 "MERGED" => PrLifecycle::Merged,
1140 "CLOSED" => PrLifecycle::Closed,
1141 other => bail!("unknown pull request state `{other}`"),
1142 };
1143
1144 let mut failing = Vec::new();
1145 let mut pending = false;
1146 let mut unknown = false;
1147 for check in &raw.status_check_rollup {
1148 match check.verdict() {
1149 Verdict::Pass => {}
1150 Verdict::Pending => pending = true,
1151 Verdict::Fail => failing.push(check.label()),
1152 Verdict::Unknown => unknown = true,
1153 }
1154 }
1155 let checks = if raw.status_check_rollup.is_empty() {
1156 Checks::Unknown
1157 } else if pending {
1158 Checks::Pending
1159 } else if !failing.is_empty() {
1160 Checks::Red
1161 } else if unknown {
1162 Checks::Unknown
1163 } else {
1164 Checks::Green
1165 };
1166
1167 let mut review_comments = Vec::new();
1168 for r in raw.reviews {
1169 push_if_outstanding(
1170 &mut review_comments,
1171 ReviewComment {
1172 author: r.author.login,
1173 path: None,
1174 line: None,
1175 body: r.body,
1176 },
1177 );
1178 }
1179 for c in raw.comments {
1180 push_if_outstanding(
1181 &mut review_comments,
1182 ReviewComment {
1183 author: c.author.login,
1184 path: None,
1185 line: None,
1186 body: c.body,
1187 },
1188 );
1189 }
1190
1191 Ok(PrState {
1192 url: raw.url,
1193 number: raw.number,
1194 state,
1195 checks,
1196 failing,
1197 review_comments,
1198 blocking: Blocking::of(&raw.merge_state_status),
1199 })
1200}
1201
1202pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1212 let view = gh(
1213 repo,
1214 &[
1215 "pr".to_owned(),
1216 "view".to_owned(),
1217 pr_url.to_owned(),
1218 "--json".to_owned(),
1219 "state".to_owned(),
1220 ],
1221 )
1222 .await?;
1223 if !view.0 {
1224 bail!("gh pr view {pr_url}: {}", view.1);
1225 }
1226 Ok(parse_pr(&view.1)?.state)
1230}
1231
1232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1236pub struct ExternalMerge {
1237 pub url: String,
1239 pub number: u64,
1241}
1242
1243#[derive(Debug, Deserialize)]
1244#[serde(rename_all = "camelCase")]
1245struct GhMergedPr {
1246 url: String,
1247 number: u64,
1248 merged_at: String,
1249 base_ref_name: String,
1250}
1251
1252fn pick_merged_pr(
1266 json: &str,
1267 base_branch: &str,
1268 created_at: Timestamp,
1269) -> Result<Option<ExternalMerge>> {
1270 let raw: Vec<GhMergedPr> =
1271 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1272 let mut matches: Vec<ExternalMerge> = Vec::new();
1273 for pr in raw {
1274 if pr.base_ref_name != base_branch {
1275 continue;
1276 }
1277 let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1278 continue;
1279 };
1280 if merged_at < created_at {
1281 continue;
1282 }
1283 matches.push(ExternalMerge {
1284 url: pr.url,
1285 number: pr.number,
1286 });
1287 }
1288 if matches.len() == 1 {
1289 Ok(matches.pop())
1290 } else {
1291 Ok(None)
1292 }
1293}
1294
1295#[derive(Debug, Clone, PartialEq, Eq)]
1297pub enum OpenPr {
1298 None,
1300 One {
1302 url: String,
1304 title: String,
1306 },
1307 Many(Vec<String>),
1309}
1310
1311#[derive(Debug, Deserialize)]
1312#[serde(rename_all = "camelCase")]
1313struct GhOpenPr {
1314 url: String,
1318 #[serde(default)]
1319 title: String,
1320 base_ref_name: String,
1321}
1322
1323pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1329 let raw: Vec<GhOpenPr> =
1330 serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1331 let mut hits: Vec<GhOpenPr> = raw
1332 .into_iter()
1333 .filter(|p| p.base_ref_name == base)
1334 .collect();
1335 Ok(match hits.len() {
1336 0 => OpenPr::None,
1337 1 => {
1338 let p = hits.remove(0);
1339 OpenPr::One {
1340 url: p.url,
1341 title: p.title,
1342 }
1343 }
1344 _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1345 })
1346}
1347
1348pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1352 let (ok, out) = gh(
1353 repo,
1354 &[
1355 "pr".to_owned(),
1356 "list".to_owned(),
1357 "--head".to_owned(),
1358 branch.to_owned(),
1359 "--base".to_owned(),
1360 base.to_owned(),
1361 "--state".to_owned(),
1362 "open".to_owned(),
1363 "--json".to_owned(),
1364 "number,url,title,baseRefName".to_owned(),
1365 ],
1366 )
1367 .await?;
1368 if !ok {
1369 bail!("gh pr list failed: {out}");
1370 }
1371 pick_open_pr(&out, base)
1372}
1373
1374pub async fn set_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
1378 let (ok, out) = gh(
1379 repo,
1380 &[
1381 "pr".to_owned(),
1382 "edit".to_owned(),
1383 url.to_owned(),
1384 "--title".to_owned(),
1385 title.to_owned(),
1386 ],
1387 )
1388 .await?;
1389 if !ok {
1390 bail!("gh pr edit failed: {out}");
1391 }
1392 Ok(())
1393}
1394
1395pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1408 let Some(winner) = state.winner() else {
1409 return Ok(None);
1410 };
1411 let branch = winner.branch.clone();
1412 let out = gh(
1413 &state.repo,
1414 &[
1415 "pr".to_owned(),
1416 "list".to_owned(),
1417 "--head".to_owned(),
1418 branch.clone(),
1419 "--state".to_owned(),
1420 "merged".to_owned(),
1421 "--json".to_owned(),
1422 "url,number,mergedAt,baseRefName".to_owned(),
1423 ],
1424 )
1425 .await?;
1426 if !out.0 {
1427 bail!("gh pr list --head {branch}: {}", out.1);
1428 }
1429 pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1430}
1431
1432pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1444 let out = tokio::process::Command::new("git")
1445 .args(["merge-base", "--is-ancestor", branch, base_branch])
1446 .current_dir(repo)
1447 .quiet()
1448 .stdin(std::process::Stdio::null())
1449 .output()
1450 .await
1451 .context("spawn git merge-base --is-ancestor")?;
1452 Ok(out.status.success())
1453}
1454
1455fn forge_slug(url: &str) -> Option<(String, &str)> {
1463 let rest = url.rsplit("://").next()?;
1464 let (host, path) = rest.split_once('/')?;
1465 if host.is_empty() {
1466 return None;
1467 }
1468 Some((host.to_ascii_lowercase(), path))
1469}
1470
1471pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
1479 let (host, path) = forge_slug(url)?;
1480 let mut segments = path.split('/');
1481 let owner = segments.next()?;
1482 let repo = segments.next()?;
1483 let kind = segments.next()?;
1484 if owner.is_empty() || repo.is_empty() || kind != "pull" {
1485 return None;
1486 }
1487 Some(format!("{host}/{owner}/{repo}"))
1488}
1489
1490fn slug_of_repo_url(url: &str) -> Option<String> {
1494 let (host, path) = forge_slug(url)?;
1495 let mut segments = path.split('/');
1496 let owner = segments.next()?;
1497 let repo = segments.next()?;
1498 if owner.is_empty() || repo.is_empty() {
1499 return None;
1500 }
1501 Some(format!("{host}/{owner}/{repo}"))
1502}
1503
1504pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
1525 if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
1526 return Ok(());
1527 }
1528 bail!(
1529 "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
1530 request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
1531 actually belongs to (or, if `origin` is a fork opened against a different upstream, \
1532 verify by hand before treating this as a false positive)"
1533 );
1534}
1535
1536async fn repo_slug(repo: &Path) -> Result<String> {
1547 let out = gh(
1548 repo,
1549 &[
1550 "repo".to_owned(),
1551 "view".to_owned(),
1552 "--json".to_owned(),
1553 "url".to_owned(),
1554 ],
1555 )
1556 .await?;
1557 if !out.0 {
1558 bail!("gh repo view --json url: {}", out.1);
1559 }
1560 #[derive(Debug, Deserialize)]
1561 struct GhRepo {
1562 url: String,
1563 }
1564 let parsed: GhRepo = serde_json::from_str(&out.1)
1565 .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
1566 slug_of_repo_url(&parsed.url)
1567 .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
1568}
1569
1570pub async fn correct_manual_merge(
1617 state: &mut RunState,
1618 url: &str,
1619) -> Result<(RunStatus, RunStatus)> {
1620 let Some(pr_slug) = slug_of_pr_url(url) else {
1621 bail!(
1622 "could not parse an owner/repo out of {url}; refusing to guess which repository \
1623 this pull request belongs to"
1624 );
1625 };
1626 let run_slug = repo_slug(&state.repo).await?;
1627 ensure_same_repo(&run_slug, &pr_slug)?;
1628 correct_merge(state, url).await
1629}
1630
1631pub(crate) async fn correct_confirmed_external_merge(
1642 state: &mut RunState,
1643 url: &str,
1644) -> Result<(RunStatus, RunStatus)> {
1645 correct_merge(state, url).await
1646}
1647
1648async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
1649 match lifecycle(&state.repo, url).await? {
1650 PrLifecycle::Merged => {}
1651 other => bail!(
1652 "{url} is {}, not merged; refusing to record {} as merged on a guess",
1653 other.as_str(),
1654 state.id
1655 ),
1656 }
1657 let before = state.status;
1658 if let Err(e) = land(state, url).await {
1659 state.status = RunStatus::Blocked;
1666 state.event("fold", format!("manual-merge correction failed: {e:#}"));
1667 state.save()?;
1668 return Err(e).context(format!("confirming the merge of {url}"));
1669 }
1670 state.event(
1671 "fold",
1672 "operator recorded this pull request as a manual merge; this run never \
1673 re-entered `land`, so `bump::after_merge` did not run for it - a release \
1674 bump this change might warrant has to be filed by hand",
1675 );
1676 state.save()?;
1677 Ok((before, state.status))
1678}
1679
1680pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1687 let raw: Vec<GhInline> =
1688 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1689 let mut out = Vec::new();
1690 for c in raw {
1691 push_if_outstanding(
1692 &mut out,
1693 ReviewComment {
1694 author: c.user.login,
1695 path: c.path,
1696 line: c.line,
1697 body: c.body,
1698 },
1699 );
1700 }
1701 Ok(out)
1702}
1703
1704fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1710 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1711 return;
1712 }
1713 if comment.path.is_none() && is_noise(&comment.body) {
1714 return;
1715 }
1716 out.push(comment);
1717}
1718
1719pub fn is_noise(body: &str) -> bool {
1737 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1738 return true;
1739 }
1740 let mut content = false;
1741 for line in strip_blocks(body).lines() {
1742 let line = unquote(line);
1743 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1744 continue;
1745 }
1746 content = true;
1747 break;
1748 }
1749 !content
1750}
1751
1752fn strip_blocks(body: &str) -> String {
1754 let mut out = String::with_capacity(body.len());
1755 let mut rest = body;
1756 loop {
1757 let open = ["<!--", "<details>"]
1758 .iter()
1759 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1760 .min_by_key(|(i, _)| *i);
1761 let Some((at, tag)) = open else {
1762 out.push_str(rest);
1763 return out;
1764 };
1765 out.push_str(&rest[..at]);
1766 let after = &rest[at + tag.len()..];
1767 let close = if tag == "<!--" { "-->" } else { "</details>" };
1768 match after.find(close) {
1769 Some(end) => rest = &after[end + close.len()..],
1770 None => return out,
1772 }
1773 }
1774}
1775
1776fn unquote(line: &str) -> &str {
1778 let mut s = line.trim();
1779 while let Some(rest) = s.strip_prefix('>') {
1780 s = rest.trim_start();
1781 }
1782 s.trim()
1783}
1784
1785fn is_checklist(line: &str) -> bool {
1787 let rest = line
1788 .strip_prefix("- ")
1789 .or_else(|| line.strip_prefix("* "))
1790 .unwrap_or("");
1791 let rest = rest.trim_start();
1792 matches!(
1793 rest.get(..3),
1794 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1795 )
1796}
1797
1798fn is_decoration(line: &str) -> bool {
1800 line.starts_with('#')
1801 || line.starts_with("[!")
1802 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1803}
1804
1805fn is_banner(line: &str) -> bool {
1812 let plain = drop_spans(line, "**", "**");
1813 let plain = if plain.contains("](") {
1814 drop_spans(&plain, "[", ")")
1815 } else {
1816 plain
1817 };
1818 !plain.chars().any(char::is_alphanumeric)
1819}
1820
1821fn drop_spans(s: &str, open: &str, close: &str) -> String {
1825 let mut out = String::with_capacity(s.len());
1826 let mut rest = s;
1827 while let Some(at) = rest.find(open) {
1828 out.push_str(&rest[..at]);
1829 let after = &rest[at + open.len()..];
1830 match after.find(close) {
1831 Some(end) => rest = &after[end + close.len()..],
1832 None => return out,
1833 }
1834 }
1835 out.push_str(rest);
1836 out
1837}
1838
1839fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
1857 static LOCKS: std::sync::LazyLock<
1858 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
1859 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
1860 LOCKS
1861 .lock()
1862 .unwrap_or_else(std::sync::PoisonError::into_inner)
1863 .entry(repo.to_path_buf())
1864 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1865 .clone()
1866}
1867
1868fn repo_label(repo: &Path, pr_url: &str) -> String {
1871 let parts: Vec<&str> = pr_url.split('/').collect();
1872 if let Some(at) = parts.iter().rposition(|p| *p == "pull")
1873 && at >= 2
1874 && !parts[at - 1].is_empty()
1875 && !parts[at - 2].is_empty()
1876 {
1877 return format!("{}/{}", parts[at - 2], parts[at - 1]);
1878 }
1879 repo.file_name()
1880 .map(|n| n.to_string_lossy().into_owned())
1881 .unwrap_or_default()
1882}
1883
1884fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
1888 (pr.checks == Checks::Red).then(|| {
1889 format!(
1890 "Merged {repo_name} PR #{} with red checks: {} ({})",
1891 pr.number,
1892 if pr.failing.is_empty() {
1893 "(none named)".to_owned()
1894 } else {
1895 pr.failing.join(", ")
1896 },
1897 pr.url
1898 )
1899 })
1900}
1901
1902async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
1906 let repo_name = repo_label(&state.repo, &pr.url);
1907 let Some(summary) = red_merge_summary(&repo_name, pr) else {
1908 return;
1909 };
1910 if let Some(rec) = state.pr.as_mut() {
1911 rec.red_at_merge = pr.failing.clone();
1912 }
1913 state.event("land", summary.clone());
1914 crate::notices::raise(crate::notices::merged_red(&state.id, &summary));
1915 if let Err(e) = ask::notify_text(&state.config.notify, &state.id, &summary).await {
1916 tracing::warn!("could not notify about a merge with red checks: {e:#}");
1917 }
1918}
1919
1920pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1927 let repo = state.repo.clone();
1928 let budget = state.config.graph.land_rounds;
1929 let mut round = 0usize;
1930 let mut rebases = 0usize;
1933 let mut waited = Duration::ZERO;
1934 let mut shown: BTreeSet<String> = BTreeSet::new();
1939
1940 state.status = RunStatus::Landing;
1948 state.event("land", format!("watching {pr_url}"));
1949 state.save()?;
1950
1951 loop {
1952 let seen = observe(&repo, pr_url).await?;
1953 let mut pr = seen.pr;
1954 pr.review_comments.retain(|c| !shown.contains(&c.body));
1955 state.pr = Some(crate::run::PrRecord {
1956 url: pr.url.clone(),
1957 number: pr.number,
1958 state: pr.state.as_str().to_owned(),
1959 checks: pr.checks.as_str().to_owned(),
1960 round,
1961 rounds: budget,
1962 red_at_merge: Vec::new(),
1963 });
1964 state.save()?;
1965
1966 match decide(&pr, round, budget, waited) {
1967 Step::Wait => {
1968 if waited >= WAIT_CEILING {
1969 let why = format!(
1970 "checks were still running after {} minutes",
1971 WAIT_CEILING.as_secs() / 60
1972 );
1973 stop(state, &repo, &pr, &why).await?;
1974 return Ok(pr);
1975 }
1976 waited += POLL;
1977 tokio::time::sleep(POLL).await;
1978 }
1979 Step::Done { merged } => {
1980 state.status = if merged {
1981 RunStatus::Merged
1982 } else {
1983 RunStatus::Ready
1984 };
1985 let detail = if merged {
1986 format!("{} was merged", pr.url)
1987 } else {
1988 format!("{} was closed without merging", pr.url)
1989 };
1990 state.merge = Some(MergeOutcome {
1991 mode: MergeMode::Pr,
1992 ok: merged,
1993 detail: detail.clone(),
1994 empty: false,
1995 });
1996 state.event("land", detail);
1997 state.save()?;
1998 return Ok(pr);
1999 }
2000 Step::Merge => {
2001 let subject = merge_subject(&seen.title, &state.instruction);
2002 if state.config.graph.land_approval {
2005 match approval_gate(state, &pr, &subject).await? {
2006 ApprovalGate::Approved => {}
2007 ApprovalGate::Held => {
2008 stop(
2009 state,
2010 &repo,
2011 &pr,
2012 "the owner did not approve the merge (held or unanswered)",
2013 )
2014 .await?;
2015 return Ok(pr);
2016 }
2017 ApprovalGate::Pending => {
2025 state.parked = true;
2026 state.event(
2027 "land",
2028 "parked awaiting merge approval - resumes once answered",
2029 );
2030 state.save()?;
2031 return Ok(pr);
2032 }
2033 }
2034 }
2035 let argv = merge_argv(pr.number, &subject);
2036 let out = {
2037 let merge_lock = repo_merge_lock(&repo);
2038 let _merge_slot = merge_lock.lock().await;
2039 gh(&repo, &argv).await?
2040 };
2041 if out.0 {
2042 pr.state = PrLifecycle::Merged;
2043 state.status = RunStatus::Merged;
2044 state.merge = Some(MergeOutcome {
2045 mode: MergeMode::Pr,
2046 ok: true,
2047 detail: format!("gh {}", argv.join(" ")),
2048 empty: false,
2049 });
2050 if let Some(pr_record) = state.pr.as_mut() {
2055 pr_record.state = pr.state.as_str().to_owned();
2056 }
2057 state.event("land", format!("merged {} as `{subject}`", pr.url));
2058 announce_red_merge(state, &pr).await;
2059 state.save()?;
2060 return Ok(pr);
2061 }
2062 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
2063 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
2064 pr.state = PrLifecycle::Merged;
2065 state.status = RunStatus::Merged;
2066 state.merge = Some(outcome);
2067 if let Some(pr_record) = state.pr.as_mut() {
2068 pr_record.state = pr.state.as_str().to_owned();
2069 }
2070 state.event("land", format!("merged {} as `{subject}`", pr.url));
2071 announce_red_merge(state, &pr).await;
2072 state.save()?;
2073 return Ok(pr);
2074 }
2075 stop(
2076 state,
2077 &repo,
2078 &pr,
2079 &format!("`gh pr merge` failed: {}", out.1),
2080 )
2081 .await?;
2082 return Ok(pr);
2083 }
2084 Step::Rebase => {
2085 if rebases >= budget {
2091 let why = format!(
2092 "the base moved under this branch {budget} time(s) and it still does \
2093 not merge; rebasing again would only race it"
2094 );
2095 stop(state, &repo, &pr, &why).await?;
2096 return Ok(pr);
2097 }
2098 rebases += 1;
2099 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
2100 stop(
2101 state,
2102 &repo,
2103 &pr,
2104 "the pull request conflicts and this run has no winning branch to rebase",
2105 )
2106 .await?;
2107 return Ok(pr);
2108 };
2109 let base = state.base_branch.clone();
2110 state.event(
2111 "land",
2112 format!("{} no longer merges; rebasing onto {base}", pr.url),
2113 );
2114 state.save()?;
2115
2116 git::fetch(&repo, "origin", &base).await.ok();
2120 let scratch = state.dir().join("rebase");
2121 let onto = format!("origin/{base}");
2122 let rebased =
2123 match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
2124 Ok(crate::rebase::Rebased::Applied) => Ok(None),
2125 Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
2126 Err(e) => Err(e),
2127 };
2128 match rebased {
2129 Ok(None) => {
2130 let pushed = {
2131 let merge_lock = repo_merge_lock(&repo);
2132 let _merge_slot = merge_lock.lock().await;
2133 git::push_rewritten(&repo, "origin", &branch).await?
2134 };
2135 if !pushed.ok() {
2136 let why = format!(
2137 "rebased {branch} but could not push it: {}",
2138 pushed.stderr.trim()
2139 );
2140 stop(state, &repo, &pr, &why).await?;
2141 return Ok(pr);
2142 }
2143 state.event("land", format!("rebased {branch} onto {base}"));
2144 state.save()?;
2145 waited = Duration::ZERO;
2148 tokio::time::sleep(POLL).await;
2149 }
2150 Ok(Some(conflict)) => {
2153 let why = format!(
2154 "{} conflicts with {base} and the rebase did not apply: {}",
2155 pr.url,
2156 conflict.chars().take(600).collect::<String>()
2157 );
2158 stop(state, &repo, &pr, &why).await?;
2159 return Ok(pr);
2160 }
2161 Err(e) => {
2162 let why = format!("could not rebase {branch} onto {base}: {e:#}");
2163 stop(state, &repo, &pr, &why).await?;
2164 return Ok(pr);
2165 }
2166 }
2167 }
2168 Step::GiveUp { reason } => {
2169 stop(state, &repo, &pr, &reason).await?;
2170 return Ok(pr);
2171 }
2172 Step::Fix { reason } => {
2173 round += 1;
2174 waited = Duration::ZERO;
2175 for c in &pr.review_comments {
2176 shown.insert(c.body.clone());
2177 }
2178 state.event("land", format!("round {round}: {reason}"));
2179 state.save()?;
2180
2181 let logs = failing_logs(&repo, &seen.failing_urls).await;
2182 let was_red = pr.checks == Checks::Red;
2183 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
2184 Fixed::Committed => {}
2185 Fixed::Declined if was_red => {
2186 let why = format!(
2187 "the fixer produced no commit while {} check(s) were failing \
2188 ({}); stopping instead of looping on an unchanged tree",
2189 pr.failing.len(),
2190 pr.failing.join(", ")
2191 );
2192 stop(state, &repo, &pr, &why).await?;
2193 return Ok(pr);
2194 }
2195 Fixed::Declined => state.event(
2200 "land",
2201 format!("round {round}: fixer declined the comments, nothing committed"),
2202 ),
2203 Fixed::Failed(why) => {
2204 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
2205 return Ok(pr);
2206 }
2207 }
2208 state.save()?;
2209 }
2210 }
2211 }
2212}
2213
2214struct Seen {
2218 pr: PrState,
2219 title: String,
2220 failing_urls: Vec<(String, String)>,
2221}
2222
2223async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
2226 let view = gh(
2227 repo,
2228 &[
2229 "pr".to_owned(),
2230 "view".to_owned(),
2231 pr_url.to_owned(),
2232 "--json".to_owned(),
2233 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
2234 ],
2235 )
2236 .await?;
2237 if !view.0 {
2238 bail!("gh pr view {pr_url}: {}", view.1);
2239 }
2240 let mut pr = parse_pr(&view.1)?;
2241 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
2242
2243 let inline = gh(
2244 repo,
2245 &[
2246 "api".to_owned(),
2247 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
2248 ],
2249 )
2250 .await?;
2251 if inline.0 {
2252 match parse_inline_comments(&inline.1) {
2253 Ok(mut comments) => pr.review_comments.append(&mut comments),
2254 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
2257 }
2258 } else {
2259 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
2260 }
2261
2262 let failing_urls = raw
2263 .status_check_rollup
2264 .iter()
2265 .filter(|c| c.verdict() == Verdict::Fail)
2266 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
2267 .collect();
2268
2269 Ok(Seen {
2270 pr,
2271 title: raw.title,
2272 failing_urls,
2273 })
2274}
2275
2276enum Fixed {
2278 Committed,
2280 Declined,
2282 Failed(String),
2284}
2285
2286async fn fix_round(
2292 state: &mut RunState,
2293 pr: &PrState,
2294 round: usize,
2295 budget: usize,
2296 reason: &str,
2297 logs: &str,
2298) -> Result<Fixed> {
2299 let winner = state
2300 .winner()
2301 .cloned()
2302 .context("landing needs a winning candidate; none is recorded on this run")?;
2303 let roles = state
2304 .config
2305 .resolve_roles()
2306 .context("resolve the roster for the fix round")?;
2307 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
2311 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
2312 _ => (
2313 state
2314 .config
2315 .agent(&winner.agent)
2316 .cloned()
2317 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
2318 format!("impl-{}", winner.label),
2319 ),
2320 };
2321
2322 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
2323 let mut seat = seat_of(state, &seat_key, &spec.id);
2324 let artifacts = agent::artifacts_dir(&state.dir());
2325 let prompt = if state.config.cache_dir().is_some() {
2326 format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
2327 } else {
2328 prompt
2329 };
2330 let out = agent::invoke(
2331 &spec,
2332 &mut seat,
2333 &Invocation {
2334 cwd: &winner.worktree,
2335 prompt: &prompt,
2336 timeout: Duration::from_secs(state.config.graph.timeout_fix),
2337 allow_write: true,
2338 sessions: state.config.graph.sessions,
2339 artifacts: &artifacts,
2340 stem: &format!("land-{round}"),
2341 run: &state.id,
2342 node: "land",
2343 cache_dir: state.config.cache_dir().as_deref(),
2344 attachments: &[],
2345 },
2346 )
2347 .await;
2348 state.seats.insert(seat.key.clone(), seat);
2349
2350 match out {
2351 Ok(o) if o.quota_exhausted() => {
2352 return Ok(Fixed::Failed(
2353 "rate limited (quota); the fixer could not run".to_owned(),
2354 ));
2355 }
2356 Ok(o) if !o.usable() => {
2357 return Ok(Fixed::Failed(format!(
2358 "the fixer produced nothing usable (exit {:?}, timed out: {})",
2359 o.exit_code, o.timed_out
2360 )));
2361 }
2362 Ok(_) => {}
2363 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
2364 }
2365
2366 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2367 if let Ok(r) = git::rescue_commit(
2370 &winner.worktree,
2371 &format!("magi: land round {round} fixes (uncommitted work)"),
2372 )
2373 .await
2374 {
2375 state.note_withheld("land", &r.withheld);
2376 }
2377 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2378 if after == before {
2379 return Ok(Fixed::Declined);
2380 }
2381
2382 let remote = state.config.merge.remote.clone();
2383 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
2384 if !push.ok() {
2385 return Ok(Fixed::Failed(format!(
2386 "pushing {} to {remote} failed: {}",
2387 winner.branch, push.stderr
2388 )));
2389 }
2390 state.event(
2391 "land",
2392 format!("round {round}: pushed a fix to {}", winner.branch),
2393 );
2394 Ok(Fixed::Committed)
2395}
2396
2397pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
2399 if let Some(existing) = state.seats.get(key)
2400 && existing.agent == agent
2401 {
2402 return existing.clone();
2403 }
2404 let fresh = SeatState::new(key, agent, state.seed);
2405 state.seats.insert(key.to_owned(), fresh.clone());
2406 fresh
2407}
2408
2409fn fix_prompt(
2411 state: &RunState,
2412 pr: &PrState,
2413 round: usize,
2414 budget: usize,
2415 reason: &str,
2416 logs: &str,
2417) -> String {
2418 let mut s = format!(
2419 "Your patch is open as a pull request and it is not landing. Land round \
2420 {round} of {budget}.\n\n\
2421 Pull request: {}\n\n\
2422 What is holding it: {reason}\n\n\
2423 # The task\n\n{}\n",
2424 pr.url, state.instruction
2425 );
2426
2427 if pr.failing.is_empty() {
2428 s.push_str("\n# Failing checks\n\n(none)\n");
2429 } else {
2430 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
2431 if logs.trim().is_empty() {
2432 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
2433 } else {
2434 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
2435 }
2436 }
2437
2438 if pr.review_comments.is_empty() {
2439 s.push_str("\n# Review comments\n\n(none)\n");
2440 } else {
2441 s.push_str("\n# Review comments\n");
2442 for c in &pr.review_comments {
2443 let where_ = match (&c.path, c.line) {
2444 (Some(p), Some(l)) => format!(" ({p}:{l})"),
2445 (Some(p), None) => format!(" ({p})"),
2446 _ => String::new(),
2447 };
2448 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
2449 }
2450 }
2451
2452 s.push_str(
2453 "\n# Rules\n\n\
2454 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
2455 failing test; do not silence a lint with an allow attribute; do not \
2456 stretch a timeout to hide a race. If the check is right, the code is \
2457 wrong.\n\
2458 2. Change nothing the checks and the comments did not raise. A \
2459 drive-by refactor turns a one-line fix into a pull request that \
2460 needs reviewing again.\n\
2461 3. If a comment is wrong, say so with a checkable argument and change \
2462 nothing for it. A declined comment with a reason is a correct \
2463 outcome; a change made to appease a reviewer is not.\n\
2464 4. Commit in this worktree. magi pushes to the pull request's branch \
2465 for you; do not push, merge, or close anything yourself.\n\
2466 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
2467 # Output\n\n\
2468 Say what you changed and why, and what you declined and why.",
2469 );
2470
2471 let language = &state.config.graph.language;
2472 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
2473 let _ = write!(s, "\n\nWrite all prose in {language}.");
2474 }
2475 s.push_str(&crate::prompt::github_english(language));
2477 if let Some(overlay) = state.config.prompts.overlay("fix") {
2478 let _ = write!(s, "\n\n{overlay}");
2479 }
2480 s
2481}
2482
2483async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
2486 let mut out = String::new();
2487 for (name, url) in failing.iter().take(MAX_LOGS) {
2488 let args = match (job_of(url), run_of(url)) {
2489 (Some(job), _) => vec![
2490 "run".to_owned(),
2491 "view".to_owned(),
2492 "--log-failed".to_owned(),
2493 "--job".to_owned(),
2494 job,
2495 ],
2496 (None, Some(run)) => vec![
2497 "run".to_owned(),
2498 "view".to_owned(),
2499 run,
2500 "--log-failed".to_owned(),
2501 ],
2502 (None, None) => continue,
2504 };
2505 let (ok, body) = match gh(repo, &args).await {
2506 Ok(v) => v,
2507 Err(e) => (false, format!("{e:#}")),
2508 };
2509 if !ok && body.trim().is_empty() {
2510 continue;
2511 }
2512 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
2513 }
2514 out
2515}
2516
2517fn job_of(details_url: &str) -> Option<String> {
2520 let after = details_url.split("/job/").nth(1)?;
2521 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2522 (!id.is_empty()).then_some(id)
2523}
2524
2525fn run_of(details_url: &str) -> Option<String> {
2527 let after = details_url.split("/actions/runs/").nth(1)?;
2528 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2529 (!id.is_empty()).then_some(id)
2530}
2531
2532fn stop_comment(run_id: &str, why: &str) -> String {
2536 format!(
2537 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
2538 The branch is untouched and the run is `{run_id}`. Nothing was merged."
2539 )
2540}
2541
2542async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
2547 let body = stop_comment(&state.id, why);
2548 let posted = gh(
2549 repo,
2550 &[
2551 "pr".to_owned(),
2552 "comment".to_owned(),
2553 pr.number.to_string(),
2554 "--body".to_owned(),
2555 body,
2556 ],
2557 )
2558 .await;
2559 match posted {
2560 Ok((true, _)) => {}
2561 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
2562 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
2563 }
2564 state.status = RunStatus::Blocked;
2565 state.merge = Some(MergeOutcome {
2566 mode: MergeMode::Pr,
2567 ok: false,
2568 detail: why.to_owned(),
2569 empty: false,
2570 });
2571 state.event("land", format!("stopped: {why}"));
2572 state.save()?;
2573 Ok(())
2574}
2575
2576async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
2592 let out = tokio::process::Command::new("gh")
2593 .args(args)
2594 .current_dir(cwd)
2595 .env_remove("GH_REPO")
2596 .quiet()
2597 .stdin(std::process::Stdio::null())
2598 .output()
2599 .await
2600 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
2601 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
2602 let err = String::from_utf8_lossy(&out.stderr);
2603 if body.trim().is_empty() {
2604 body = err.into_owned();
2605 } else if !err.trim().is_empty() {
2606 body.push_str(&err);
2607 }
2608 Ok((out.status.success(), body.trim().to_owned()))
2609}
2610
2611#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2613enum Verdict {
2614 Pass,
2615 Fail,
2616 Pending,
2617 Unknown,
2618}
2619
2620#[derive(Debug, Deserialize)]
2621#[serde(rename_all = "camelCase")]
2622struct GhPr {
2623 #[serde(default)]
2624 url: String,
2625 #[serde(default)]
2626 number: u64,
2627 #[serde(default)]
2628 state: String,
2629 #[serde(default)]
2630 title: String,
2631 #[serde(default)]
2632 status_check_rollup: Vec<GhCheck>,
2633 #[serde(default)]
2640 merge_state_status: String,
2641 #[serde(default)]
2642 reviews: Vec<GhReview>,
2643 #[serde(default)]
2644 comments: Vec<GhComment>,
2645}
2646
2647#[derive(Debug, Deserialize)]
2652#[serde(rename_all = "camelCase")]
2653struct GhCheck {
2654 #[serde(default)]
2655 name: Option<String>,
2656 #[serde(default)]
2657 context: Option<String>,
2658 #[serde(default)]
2659 status: Option<String>,
2660 #[serde(default)]
2661 conclusion: Option<String>,
2662 #[serde(default)]
2663 state: Option<String>,
2664 #[serde(default)]
2665 details_url: Option<String>,
2666 #[serde(default)]
2667 target_url: Option<String>,
2668}
2669
2670impl GhCheck {
2671 fn label(&self) -> String {
2673 self.name
2674 .clone()
2675 .or_else(|| self.context.clone())
2676 .unwrap_or_else(|| "(unnamed check)".to_owned())
2677 }
2678
2679 fn url(&self) -> Option<&str> {
2681 self.details_url
2682 .as_deref()
2683 .or(self.target_url.as_deref())
2684 .filter(|u| !u.is_empty())
2685 }
2686
2687 fn verdict(&self) -> Verdict {
2695 if let Some(status) = self.status.as_deref() {
2696 if !status.eq_ignore_ascii_case("COMPLETED") {
2697 return Verdict::Pending;
2698 }
2699 }
2700 let outcome = self
2701 .conclusion
2702 .as_deref()
2703 .or(self.state.as_deref())
2704 .unwrap_or("");
2705 match outcome.to_ascii_uppercase().as_str() {
2706 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
2707 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
2708 | "ACTION_REQUIRED" => Verdict::Fail,
2709 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
2710 Verdict::Pending
2711 }
2712 _ => Verdict::Unknown,
2713 }
2714 }
2715}
2716
2717#[derive(Debug, Deserialize)]
2718struct GhAuthor {
2719 #[serde(default)]
2720 login: String,
2721}
2722
2723#[derive(Debug, Deserialize)]
2724struct GhReview {
2725 #[serde(default)]
2726 author: GhAuthor,
2727 #[serde(default)]
2728 body: String,
2729}
2730
2731#[derive(Debug, Deserialize)]
2732struct GhComment {
2733 #[serde(default)]
2734 author: GhAuthor,
2735 #[serde(default)]
2736 body: String,
2737}
2738
2739#[derive(Debug, Deserialize)]
2740struct GhUser {
2741 #[serde(default)]
2742 login: String,
2743}
2744
2745#[derive(Debug, Deserialize)]
2746struct GhInline {
2747 #[serde(default)]
2748 user: GhUser,
2749 #[serde(default)]
2750 path: Option<String>,
2751 #[serde(default)]
2752 line: Option<u64>,
2753 #[serde(default)]
2754 body: String,
2755}
2756
2757impl Default for GhAuthor {
2758 fn default() -> Self {
2759 Self {
2760 login: "(unknown)".to_owned(),
2761 }
2762 }
2763}
2764
2765impl Default for GhUser {
2766 fn default() -> Self {
2767 Self {
2768 login: "(unknown)".to_owned(),
2769 }
2770 }
2771}
2772
2773#[cfg(test)]
2774mod tests {
2775 use super::*;
2776 use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
2777
2778 const GREEN_OPEN: &str = r####"{
2780 "url": "https://github.com/yukimemi/magi/pull/10",
2781 "number": 10,
2782 "state": "OPEN",
2783 "mergeStateStatus": "CLEAN",
2784 "statusCheckRollup": [
2785 {
2786 "__typename": "CheckRun",
2787 "conclusion": "SKIPPED",
2788 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2789 "name": "review",
2790 "status": "COMPLETED",
2791 "workflowName": "claude-review"
2792 },
2793 {
2794 "__typename": "CheckRun",
2795 "conclusion": "SUCCESS",
2796 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2797 "name": "check (ubuntu-latest)",
2798 "status": "COMPLETED",
2799 "workflowName": "CI"
2800 },
2801 {
2802 "__typename": "CheckRun",
2803 "conclusion": "SUCCESS",
2804 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2805 "name": "rustfmt",
2806 "status": "COMPLETED",
2807 "workflowName": "CI"
2808 },
2809 {
2810 "__typename": "StatusContext",
2811 "context": "CodeRabbit",
2812 "state": "SUCCESS",
2813 "targetUrl": ""
2814 }
2815 ],
2816 "reviews": [],
2817 "comments": [
2818 {
2819 "author": {
2820 "login": "coderabbitai"
2821 },
2822 "authorAssociation": "NONE",
2823 "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"
2824 }
2825 ]
2826}"####;
2827
2828 const RED_OPEN: &str = r####"{
2830 "url": "https://github.com/yukimemi/magi/pull/9",
2831 "number": 9,
2832 "state": "OPEN",
2833 "mergeStateStatus": "UNSTABLE",
2834 "statusCheckRollup": [
2835 {
2836 "__typename": "CheckRun",
2837 "conclusion": "SUCCESS",
2838 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2839 "name": "check (ubuntu-latest)",
2840 "status": "COMPLETED",
2841 "workflowName": "CI"
2842 },
2843 {
2844 "__typename": "CheckRun",
2845 "conclusion": "SUCCESS",
2846 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2847 "name": "rustfmt",
2848 "status": "COMPLETED",
2849 "workflowName": "CI"
2850 },
2851 {
2852 "__typename": "CheckRun",
2853 "conclusion": "FAILURE",
2854 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2855 "name": "editorconfig",
2856 "status": "COMPLETED",
2857 "workflowName": "CI"
2858 },
2859 {
2860 "__typename": "StatusContext",
2861 "context": "CodeRabbit",
2862 "state": "SUCCESS",
2863 "targetUrl": ""
2864 }
2865 ],
2866 "reviews": [],
2867 "comments": [
2868 {
2869 "author": {
2870 "login": "coderabbitai"
2871 },
2872 "authorAssociation": "NONE",
2873 "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"
2874 }
2875 ]
2876}"####;
2877
2878 const PENDING_OPEN: &str = r####"{
2880 "url": "https://github.com/yukimemi/magi/pull/9",
2881 "number": 9,
2882 "state": "OPEN",
2883 "statusCheckRollup": [
2884 {
2885 "__typename": "CheckRun",
2886 "conclusion": "SUCCESS",
2887 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2888 "name": "check (ubuntu-latest)",
2889 "status": "COMPLETED",
2890 "workflowName": "CI"
2891 },
2892 {
2893 "__typename": "CheckRun",
2894 "conclusion": "SUCCESS",
2895 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2896 "name": "rustfmt",
2897 "status": "COMPLETED",
2898 "workflowName": "CI"
2899 },
2900 {
2901 "__typename": "CheckRun",
2902 "conclusion": null,
2903 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2904 "name": "editorconfig",
2905 "status": "IN_PROGRESS",
2906 "workflowName": "CI"
2907 },
2908 {
2909 "__typename": "StatusContext",
2910 "context": "CodeRabbit",
2911 "state": "SUCCESS",
2912 "targetUrl": ""
2913 }
2914 ],
2915 "reviews": [],
2916 "comments": []
2917}"####;
2918
2919 const MERGED: &str = r####"{
2921 "url": "https://github.com/yukimemi/magi/pull/16",
2922 "number": 16,
2923 "state": "MERGED",
2924 "statusCheckRollup": [
2925 {
2926 "__typename": "CheckRun",
2927 "conclusion": "SUCCESS",
2928 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2929 "name": "check (ubuntu-latest)",
2930 "status": "COMPLETED",
2931 "workflowName": "CI"
2932 },
2933 {
2934 "__typename": "CheckRun",
2935 "conclusion": "SUCCESS",
2936 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2937 "name": "review",
2938 "status": "COMPLETED",
2939 "workflowName": "claude-review"
2940 }
2941 ],
2942 "reviews": [],
2943 "comments": []
2944}"####;
2945
2946 const REVIEWED_OPEN: &str = r####"{
2948 "url": "https://github.com/yukimemi/magi/pull/12",
2949 "number": 12,
2950 "state": "OPEN",
2951 "statusCheckRollup": [
2952 {
2953 "__typename": "CheckRun",
2954 "conclusion": "SUCCESS",
2955 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2956 "name": "check (ubuntu-latest)",
2957 "status": "COMPLETED",
2958 "workflowName": "CI"
2959 },
2960 {
2961 "__typename": "CheckRun",
2962 "conclusion": "SUCCESS",
2963 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2964 "name": "review",
2965 "status": "COMPLETED",
2966 "workflowName": "claude-review"
2967 }
2968 ],
2969 "reviews": [
2970 {
2971 "author": {
2972 "login": "claude"
2973 },
2974 "state": "COMMENTED",
2975 "body": ""
2976 }
2977 ],
2978 "comments": [
2979 {
2980 "author": {
2981 "login": "coderabbitai"
2982 },
2983 "authorAssociation": "NONE",
2984 "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"
2985 },
2986 {
2987 "author": {
2988 "login": "claude"
2989 },
2990 "authorAssociation": "NONE",
2991 "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"
2992 }
2993 ]
2994}"####;
2995
2996 const INLINE: &str = r####"[
2998 {
2999 "user": {
3000 "login": "claude[bot]"
3001 },
3002 "path": "src/graph.rs",
3003 "line": 231,
3004 "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"
3005 }
3006]"####;
3007
3008 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
3010<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
3011
3012> [!IMPORTANT]
3013> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
3014>
3015> This repository does not receive automatic reviews because it has fewer than 10 stars.
3016>
3017> <details>
3018> <summary>⚙️ Run configuration</summary>
3019>
3020> **Configuration used**: defaults
3021>
3022> **Review profile**: CHILL
3023>
3024> **Plan**: Team
3025>
3026> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
3027>
3028> </details>
3029
3030<!-- end of auto-generated comment: skip review by coderabbit.ai -->
3031
3032<!-- tips_start -->
3033
3034---
3035
3036Thanks 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.
3037
3038<details>
3039<summary>❤️ Share</summary>
3040
3041- [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"####;
3042
3043 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
3045
3046---
3047### Reviewing PR #16
3048
3049- [x] Read AGENTS.md conventions
3050- [x] Review `src/daemon.rs` changes
3051- [x] Review `src/main.rs` changes (new `doctor` reporting)
3052- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
3053- [x] Check test coverage for new behavior
3054- [x] Run verification commands (blocked — see note)
3055- [x] Post findings"####;
3056
3057 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
3059
3060---
3061### Review: `magi review <branch>` — cheap-half-only graph
3062
3063Read 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.
3064
3065**Correctness**
3066
3067- 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"####;
3068
3069 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
3070 PrState {
3071 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
3072 number: 16,
3073 state: PrLifecycle::Open,
3074 checks,
3075 blocking: if matches!(checks, Checks::Red) {
3079 Blocking::Yes
3080 } else {
3081 Blocking::No
3082 },
3083 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
3084 review_comments: (0..comments)
3085 .map(|i| ReviewComment {
3086 author: "coderabbitai".to_owned(),
3087 path: Some("src/graph.rs".to_owned()),
3088 line: Some(231),
3089 body: format!("finding {i}"),
3090 })
3091 .collect(),
3092 }
3093 }
3094
3095 #[test]
3096 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
3097 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
3098 assert_eq!(state.number, 10);
3099 assert_eq!(state.state, PrLifecycle::Open);
3100 assert_eq!(state.checks, Checks::Green);
3101 assert!(state.failing.is_empty());
3102 assert!(
3103 state.review_comments.is_empty(),
3104 "the only comment is CodeRabbit's trigger notice: {:?}",
3105 state.review_comments
3106 );
3107 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
3108 }
3109
3110 #[test]
3111 fn a_failing_check_parses_as_red_and_is_named() {
3112 let state = parse_pr(RED_OPEN).expect("red fixture parses");
3113 assert_eq!(state.checks, Checks::Red);
3114 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
3115 let mut blocking = state.clone();
3122 blocking.blocking = Blocking::Yes;
3123 match decide(&blocking, 0, 4, Duration::ZERO) {
3124 Step::Fix { reason } => {
3125 assert!(reason.contains("editorconfig"), "reason: {reason}");
3126 assert!(reason.contains("failing"), "reason: {reason}");
3127 }
3128 other => panic!("expected a fix round, got {other:?}"),
3129 }
3130 }
3131
3132 #[test]
3133 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
3134 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
3135 assert_eq!(state.checks, Checks::Pending);
3136 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
3137 }
3138
3139 #[test]
3140 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
3141 let state = parse_pr(MERGED).expect("merged fixture parses");
3142 assert_eq!(state.state, PrLifecycle::Merged);
3143 assert_eq!(
3144 decide(&state, 0, 4, Duration::ZERO),
3145 Step::Done { merged: true }
3146 );
3147 }
3148
3149 #[test]
3150 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
3151 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
3152 assert_eq!(state.checks, Checks::Green);
3153 let authors: Vec<&str> = state
3154 .review_comments
3155 .iter()
3156 .map(|c| c.author.as_str())
3157 .collect();
3158 assert_eq!(
3159 authors,
3160 vec!["claude"],
3161 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
3162 );
3163 match decide(&state, 0, 4, Duration::ZERO) {
3164 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
3165 other => panic!("expected a fix round, got {other:?}"),
3166 }
3167 }
3168
3169 #[test]
3170 fn inline_review_comments_keep_their_file_and_line() {
3171 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
3172 assert_eq!(comments.len(), 1);
3173 assert_eq!(comments[0].author, "claude[bot]");
3174 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
3175 assert_eq!(comments[0].line, Some(231));
3176 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
3177 }
3178
3179 #[test]
3180 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
3181 assert!(
3182 is_noise(CODERABBIT_TRIGGER),
3183 "CodeRabbit's trigger notice declares itself not a review"
3184 );
3185 assert!(
3186 is_noise(CLAUDE_CHECKLIST),
3187 "a progress checklist asks for nothing"
3188 );
3189 assert!(
3190 !is_noise(CLAUDE_FINDING),
3191 "a review that names a bug is input, not noise"
3192 );
3193
3194 let mut clean = pr(Checks::Green, &[], 0);
3195 clean.review_comments.push(ReviewComment {
3196 author: "coderabbitai".to_owned(),
3197 path: None,
3198 line: None,
3199 body: CODERABBIT_TRIGGER.to_owned(),
3200 });
3201 clean.review_comments.retain(|c| !is_noise(&c.body));
3202 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
3203
3204 let mut found = pr(Checks::Green, &[], 0);
3205 found.review_comments.push(ReviewComment {
3206 author: "claude".to_owned(),
3207 path: None,
3208 line: None,
3209 body: CLAUDE_FINDING.to_owned(),
3210 });
3211 found.review_comments.retain(|c| !is_noise(&c.body));
3212 assert!(matches!(
3213 decide(&found, 0, 4, Duration::ZERO),
3214 Step::Fix { .. }
3215 ));
3216 }
3217
3218 #[test]
3219 fn the_policy_table_holds_for_every_combination_that_matters() {
3220 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
3221 (
3222 "pending checks are waited for, even on the last round",
3223 pr(Checks::Pending, &[], 0),
3224 4,
3225 4,
3226 Duration::ZERO,
3227 Step::Wait,
3228 ),
3229 (
3230 "red checks are fixed",
3231 pr(Checks::Red, &["editorconfig"], 0),
3232 0,
3233 4,
3234 Duration::ZERO,
3235 Step::Fix {
3236 reason: "1 check(s) failing: editorconfig".to_owned(),
3237 },
3238 ),
3239 (
3240 "green with comments is fixed, not merged",
3241 pr(Checks::Green, &[], 2),
3242 1,
3243 4,
3244 Duration::ZERO,
3245 Step::Fix {
3246 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
3247 .to_owned(),
3248 },
3249 ),
3250 (
3251 "green and clean merges",
3252 pr(Checks::Green, &[], 0),
3253 3,
3254 4,
3255 Duration::ZERO,
3256 Step::Merge,
3257 ),
3258 (
3259 "an unreadable rollup is waited on while the grace lasts",
3260 pr(Checks::Unknown, &[], 0),
3261 0,
3262 4,
3263 Duration::ZERO,
3264 Step::Wait,
3265 ),
3266 (
3267 "an unreadable rollup is never merged once the grace is spent",
3268 pr(Checks::Unknown, &[], 0),
3269 0,
3270 4,
3271 CHECKS_GRACE,
3272 Step::GiveUp {
3273 reason: "no check status is readable on the pull request after 3 minute(s); \
3274 refusing to merge on a guess"
3275 .to_owned(),
3276 },
3277 ),
3278 ];
3279 for (what, state, round, budget, waited, want) in cases {
3280 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
3281 }
3282 }
3283
3284 #[test]
3285 fn the_forge_verdict_survives_the_round_trip_from_gh() {
3286 let green = parse_pr(GREEN_OPEN).expect("parse");
3290 assert_eq!(green.blocking, Blocking::No);
3291 let red = parse_pr(RED_OPEN).expect("parse");
3292 assert_eq!(
3293 red.blocking,
3294 Blocking::No,
3295 "`UNSTABLE` is mergeable: the red check is one nobody requires"
3296 );
3297 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
3298 let quiet =
3300 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
3301 assert_eq!(quiet.blocking, Blocking::Unsaid);
3302 }
3303
3304 #[test]
3305 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
3306 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
3312 nonblocking.blocking = Blocking::No;
3313 assert_eq!(
3314 decide(&nonblocking, 0, 4, Duration::ZERO),
3315 Step::Merge,
3316 "the forge says nothing is in the way, so nothing is"
3317 );
3318
3319 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
3321 blocking.blocking = Blocking::Yes;
3322 assert!(matches!(
3323 decide(&blocking, 0, 4, Duration::ZERO),
3324 Step::Fix { .. }
3325 ));
3326
3327 let mut commented = pr(Checks::Red, &["coverage"], 1);
3330 commented.blocking = Blocking::No;
3331 assert!(matches!(
3332 decide(&commented, 0, 4, Duration::ZERO),
3333 Step::Fix { .. }
3334 ));
3335
3336 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
3338 unsaid.blocking = Blocking::Unsaid;
3339 assert!(matches!(
3340 decide(&unsaid, 0, 4, Duration::ZERO),
3341 Step::Fix { .. }
3342 ));
3343 }
3344
3345 #[test]
3346 fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
3347 let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
3348 red.blocking = Blocking::No;
3349 assert_eq!(
3350 decide(&red, 0, 4, Duration::ZERO),
3351 Step::Merge,
3352 "announcing must not change the decision"
3353 );
3354 let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
3355 assert!(said.contains("yukimemi/magi"), "{said}");
3356 assert!(said.contains("#16"), "{said}");
3357 assert!(
3358 said.contains("https://github.com/yukimemi/magi/pull/16"),
3359 "{said}"
3360 );
3361 assert!(
3362 said.contains("test (windows-latest)") && said.contains("coverage"),
3363 "{said}"
3364 );
3365
3366 let green = pr(Checks::Green, &["stale"], 0);
3368 assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
3369 }
3370
3371 #[test]
3372 fn the_repo_label_comes_from_the_pull_request_url() {
3373 let p = Path::new("/tmp/checkout");
3374 assert_eq!(
3375 repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
3376 "yukimemi/magi"
3377 );
3378 assert_eq!(repo_label(p, "not a url"), "checkout");
3379 }
3380
3381 #[test]
3382 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
3383 let mut conflicted = pr(Checks::Green, &[], 0);
3388 conflicted.blocking = Blocking::Conflict;
3389 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
3390
3391 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
3395 red.blocking = Blocking::Conflict;
3396 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
3397
3398 let mut merged = pr(Checks::Red, &[], 0);
3400 merged.blocking = Blocking::Conflict;
3401 merged.state = PrLifecycle::Merged;
3402 assert_eq!(
3403 decide(&merged, 0, 4, Duration::ZERO),
3404 Step::Done { merged: true }
3405 );
3406 }
3407
3408 #[test]
3409 fn the_forge_verdict_is_read_off_merge_state_status() {
3410 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
3413 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
3414 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
3415 }
3416 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
3417 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
3418 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
3419 for quiet in ["", "UNKNOWN"] {
3422 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
3423 assert!(Blocking::of(quiet).stops_a_merge());
3424 }
3425 }
3426
3427 #[test]
3428 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
3429 let argv = merge_argv(28, "fix: retry uploads on transient network errors");
3430 let jj = "could not determine current branch: failed to run git: not on any branch";
3432
3433 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
3434 .expect("the forge says merged, so it merged");
3435 assert!(landed.ok);
3436 assert!(
3437 landed.detail.contains("but the pull request is merged"),
3438 "the record must not read as a clean success: {}",
3439 landed.detail
3440 );
3441 assert!(
3442 landed.detail.contains("not on any branch"),
3443 "and it must keep what the command actually said: {}",
3444 landed.detail
3445 );
3446
3447 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
3449 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
3450 assert!(merged_after_all(&argv, jj, None).is_none());
3452 }
3453
3454 #[test]
3455 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
3456 let mut state = pr(Checks::Red, &["editorconfig"], 3);
3457 state.state = PrLifecycle::Closed;
3458 assert_eq!(
3459 decide(&state, 0, 4, Duration::ZERO),
3460 Step::Done { merged: false },
3461 "a human closing the pull request ends the loop, whatever CI says"
3462 );
3463 }
3464
3465 #[test]
3466 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
3467 let red = decide(
3468 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
3469 4,
3470 4,
3471 Duration::ZERO,
3472 );
3473 match red {
3474 Step::GiveUp { reason } => {
3475 assert!(reason.contains("editorconfig"), "reason: {reason}");
3476 assert!(reason.contains("test (macos)"), "reason: {reason}");
3477 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
3478 }
3479 other => panic!("expected a give-up, got {other:?}"),
3480 }
3481
3482 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
3483 match commented {
3484 Step::GiveUp { reason } => {
3485 assert!(reason.contains("unresolved"), "reason: {reason}");
3486 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
3487 }
3488 other => panic!("expected a give-up, got {other:?}"),
3489 }
3490 }
3491
3492 #[test]
3493 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
3494 let candidate_commit = "magi: candidate A (uncommitted work)";
3495 let subject = merge_subject(candidate_commit, "add retries to the uploader");
3496 let argv = merge_argv(16, &subject);
3497
3498 assert!(argv.contains(&"--squash".to_owned()));
3499 assert!(argv.contains(&"--delete-branch".to_owned()));
3500 assert!(argv.contains(&"--subject".to_owned()));
3501 assert_eq!(
3502 argv.last().map(String::as_str),
3503 Some("add retries to the uploader"),
3504 "the subject must not be the candidate commit message"
3505 );
3506 assert_ne!(subject, candidate_commit);
3507 }
3508
3509 #[test]
3510 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
3511 assert_eq!(
3512 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
3513 "feat: a queue, an unattended loop, and a phone UI"
3514 );
3515 assert_eq!(
3516 merge_subject("", "# port the retry logic\n\ndetails"),
3517 "port the retry logic",
3518 "an empty title falls back to the task's first line, heading marks stripped"
3519 );
3520 }
3521
3522 #[test]
3523 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
3524 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
3525 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
3526 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
3527 assert_eq!(job_of("https://coderabbit.ai/status"), None);
3528 assert_eq!(run_of(""), None);
3529 }
3530
3531 #[test]
3532 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
3533 let mut out = Vec::new();
3534 push_if_outstanding(
3535 &mut out,
3536 ReviewComment {
3537 author: "yukimemi".to_owned(),
3538 path: None,
3539 line: None,
3540 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
3541 },
3542 );
3543 assert!(out.is_empty());
3544 }
3545
3546 fn run_state() -> RunState {
3550 RunState::new(
3551 std::path::PathBuf::from("/repo/magi"),
3552 "main".to_owned(),
3553 "abcdef1234".to_owned(),
3554 "add retries to the uploader".to_owned(),
3555 crate::config::Config::default(),
3556 )
3557 }
3558
3559 fn green_pr() -> PrState {
3560 PrState {
3561 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
3562 number: 42,
3563 state: PrLifecycle::Open,
3564 checks: Checks::Green,
3565 blocking: Blocking::No,
3567 failing: Vec::new(),
3568 review_comments: vec![ReviewComment {
3569 author: "coderabbitai".to_owned(),
3570 path: Some("src/land.rs".to_owned()),
3571 line: Some(212),
3572 body: "this branch never checks the exit code".to_owned(),
3573 }],
3574 }
3575 }
3576
3577 #[test]
3578 fn github_facing_land_text_is_english_whatever_the_language() {
3579 let mut state = run_state();
3580 state.config.graph.language = "ja".to_owned();
3581 let comment = stop_comment(&state.id, "checks are still red");
3582 assert!(comment.is_ascii(), "{comment}");
3583 assert!(comment.starts_with(MARKER));
3584
3585 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3586 let ja_at = p.find("Write all prose in ja").unwrap();
3587 let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
3588 assert!(ja_at < rule_at, "{p}");
3589 assert!(p.contains("stays in Japanese"), "{p}");
3590
3591 state.config.graph.language = "en".to_owned();
3592 let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3593 assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
3594 assert!(!p.contains("does not apply"), "{p}");
3595 }
3596
3597 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
3598
3599 fn panel() -> String {
3600 approval_panel(
3601 &run_state(),
3602 &green_pr(),
3603 NUMSTAT,
3604 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
3605 &[
3606 "land: ask before merging".to_owned(),
3607 "land: colour the diff".to_owned(),
3608 ],
3609 "feat: merge approval from the phone",
3610 )
3611 }
3612
3613 #[test]
3614 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
3615 let html = panel();
3616 for needle in [
3617 "42",
3618 "main",
3619 "src/land.rs",
3620 "src/web.rs",
3621 "assets/logo.png",
3622 "feat: merge approval from the phone",
3623 "land: ask before merging",
3624 "land: colour the diff",
3625 "coderabbitai",
3626 "this branch never checks the exit code",
3627 "green",
3628 ] {
3629 assert!(html.contains(needle), "the panel must state `{needle}`");
3630 }
3631 }
3632
3633 fn winning_candidate(summary: &str) -> Candidate {
3636 Candidate {
3637 index: 0,
3638 label: 'A',
3639 agent: "opus".to_owned(),
3640 branch: "magi/x/A".to_owned(),
3641 worktree: PathBuf::from("/wt/A"),
3642 summary: summary.to_owned(),
3643 stat: String::new(),
3644 files: 1,
3645 commits: 1,
3646 empty: false,
3647 failed: None,
3648 verified_noop: None,
3649 duration_ms: 0,
3650 folded: false,
3651 }
3652 }
3653
3654 fn uncontested_tally() -> Tally {
3655 Tally {
3656 first_choice: BTreeMap::from([('A', 1)]),
3657 borda: BTreeMap::new(),
3658 winner: 'A',
3659 rankings: 1,
3660 unanimous_initial: true,
3661 deliberated: false,
3662 changed_votes: 0,
3663 unanimous_final: true,
3664 tie_break: None,
3665 judges: 1,
3666 present: 1,
3667 quorum: 1,
3668 met_quorum: true,
3669 uncontested: None,
3670 }
3671 }
3672
3673 fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
3674 ReviewRecord {
3675 attempts: 0,
3676 reviewer,
3677 agent: agent.to_owned(),
3678 summary: summary.to_owned(),
3679 findings: Vec::new(),
3680 vote: None,
3681 failed: None,
3682 duration_ms: 0,
3683 }
3684 }
3685
3686 fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
3687 let answered = reviews.len();
3688 ReviewRound {
3689 round,
3690 head: "abc1234".to_owned(),
3691 verified_head: None,
3692 verified_at: None,
3693 reviews,
3694 e2e: Vec::new(),
3695 verify_retried: false,
3696 e2e_deferred: false,
3697 e2e_defer_reason: None,
3698 fix: None,
3699 blocking: 0,
3700 answered,
3701 expected: answered,
3702 clean: true,
3703 progressed: false,
3704 vote_split: false,
3705 reconsideration: Vec::new(),
3706 verdict: None,
3707 }
3708 }
3709
3710 #[test]
3711 fn the_approval_panel_states_the_task_verbatim_in_either_language() {
3712 let en = panel();
3713 assert!(en.contains("Task"), "{en}");
3714 assert!(en.contains("add retries to the uploader"), "{en}");
3715
3716 let mut state = run_state();
3717 state.config.graph.language = "ja".to_owned();
3718 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3719 assert!(ja.contains("タスク"), "{ja}");
3720 assert!(
3721 ja.contains("add retries to the uploader"),
3722 "the task itself is not translated: {ja}"
3723 );
3724 }
3725
3726 #[test]
3727 fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
3728 let html = panel();
3732 assert!(!html.contains("What changed"), "{html}");
3733 assert!(!html.contains("Review verdict"), "{html}");
3734 }
3735
3736 #[test]
3737 fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
3738 let mut state = run_state();
3739 state.candidates = vec![winning_candidate("")];
3740 state.tally = Some(uncontested_tally());
3741 let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3742 assert!(
3743 !html.contains("What changed"),
3744 "an empty summary must not render an empty box: {html}"
3745 );
3746 }
3747
3748 #[test]
3749 fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
3750 let mut state = run_state();
3751 state.candidates = vec![winning_candidate(
3752 "Added a retry loop around the uploader PUT call.",
3753 )];
3754 state.tally = Some(uncontested_tally());
3755 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3756 assert!(en.contains("What changed"), "{en}");
3757 assert!(
3758 en.contains("Added a retry loop around the uploader PUT call."),
3759 "{en}"
3760 );
3761
3762 state.config.graph.language = "ja".to_owned();
3763 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3764 assert!(ja.contains("変更内容"), "{ja}");
3765 assert!(
3766 ja.contains("Added a retry loop around the uploader PUT call."),
3767 "{ja}"
3768 );
3769 }
3770
3771 #[test]
3772 fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
3773 let mut state = run_state();
3774 state.reviews = vec![
3775 review_round(
3776 1,
3777 vec![review_record(1, "alpha", "found a race, sent back")],
3778 ),
3779 review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
3780 ];
3781 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3782 assert!(en.contains("Review verdict"), "{en}");
3783 assert!(en.contains("race is fixed, clean"), "{en}");
3784 assert!(
3785 !en.contains("found a race, sent back"),
3786 "only the round that actually cleared the merge should show: {en}"
3787 );
3788
3789 state.config.graph.language = "ja".to_owned();
3790 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3791 assert!(ja.contains("レビューの結論"), "{ja}");
3792 assert!(ja.contains("レビュアー"), "{ja}");
3793 assert!(ja.contains("race is fixed, clean"), "{ja}");
3794 }
3795
3796 fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
3802 ReviewRecord {
3803 attempts: 0,
3804 reviewer,
3805 agent: agent.to_owned(),
3806 summary: String::new(),
3807 findings: Vec::new(),
3808 vote: None,
3809 failed: Some(reason.to_owned()),
3810 duration_ms: 0,
3811 }
3812 }
3813
3814 #[test]
3815 fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
3816 let mut state = run_state();
3817 state.reviews = vec![review_round(
3818 1,
3819 vec![
3820 review_record(1, "alpha", "clean, nothing to add"),
3821 unanswered_review_record(2, "beta", "timed out"),
3822 ],
3823 )];
3824 let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3825 assert!(en.contains("clean, nothing to add"), "{en}");
3826 assert!(
3827 en.contains("produced no answer: timed out"),
3828 "a seat that never answered must say so, not render a blank box: {en}"
3829 );
3830 assert!(
3831 !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
3832 "no reviewer box may be left empty: {en}"
3833 );
3834
3835 state.config.graph.language = "ja".to_owned();
3836 let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3837 assert!(ja.contains("回答なし: timed out"), "{ja}");
3838 }
3839
3840 #[test]
3841 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
3842 let html = panel();
3843 assert!(!html.contains("<script"), "no script survives the csp");
3844 assert!(!html.contains("<form"), "form-action is 'none'");
3845 let pr = green_pr();
3846 assert_eq!(
3847 html.matches("http").count(),
3848 html.matches(pr.url.as_str()).count(),
3849 "the only http url in the panel is the pull request's own link"
3850 );
3851 }
3852
3853 #[test]
3854 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
3855 let html = panel();
3856 assert!(
3857 html.contains(">+</span>"),
3858 "an added line carries a `+` in the gutter, not only a background"
3859 );
3860 assert!(
3861 html.contains(">-</span>"),
3862 "a removed line carries a `-` in the gutter, not only a background"
3863 );
3864 assert!(
3865 html.contains(">new line</span>"),
3866 "the marker is moved to the gutter, so the body is printed once without it"
3867 );
3868 }
3869
3870 #[test]
3871 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
3872 let total = DIFF_MAX_LINES + 100;
3873 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
3874 let html = approval_panel(
3875 &run_state(),
3876 &green_pr(),
3877 NUMSTAT,
3878 &diff,
3879 &[],
3880 "feat: something long",
3881 );
3882 assert!(
3883 html.contains(&format!("100 of {total} diff lines omitted")),
3884 "the note must say exactly how much was cut"
3885 );
3886 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
3887 assert!(
3888 !html.contains(&format!("line {DIFF_MAX_LINES}")),
3889 "nothing past the threshold is rendered"
3890 );
3891 assert!(
3892 html.contains("/repo/magi"),
3893 "the note says where the rest is"
3894 );
3895 }
3896
3897 #[test]
3898 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
3899 let html = approval_panel(
3900 &run_state(),
3901 &green_pr(),
3902 "1\t2\tsrc/<b>&\"x\"'.rs",
3903 "",
3904 &[],
3905 "subject",
3906 );
3907 assert!(html.contains("src/<b>&"x"'.rs"));
3908 assert!(
3909 !html.contains("<b>"),
3910 "an agent-influenced path must never become markup"
3911 );
3912 }
3913
3914 #[tokio::test]
3915 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
3916 let a = std::path::PathBuf::from("/repo/a");
3917 let b = std::path::PathBuf::from("/repo/b");
3918
3919 let held = repo_merge_lock(&a).lock_owned().await;
3920
3921 assert!(
3924 repo_merge_lock(&a).try_lock().is_err(),
3925 "a second merge into the same repository must not proceed concurrently"
3926 );
3927
3928 assert!(
3932 repo_merge_lock(&b).try_lock().is_ok(),
3933 "a different repository's merge lock must be independent"
3934 );
3935
3936 drop(held);
3937 assert!(
3938 repo_merge_lock(&a).try_lock().is_ok(),
3939 "the lock is released once the holder is done"
3940 );
3941 }
3942
3943 #[test]
3944 fn only_the_merge_choice_merges_and_silence_holds() {
3945 let table = [
3946 (None, Approval::Hold),
3947 (Some("merge"), Approval::Merge),
3948 (Some(" merge\n"), Approval::Merge),
3949 (Some("hold"), Approval::Hold),
3950 (Some(""), Approval::Hold),
3951 (Some("yes"), Approval::Hold),
3952 ];
3953 for (answer, want) in table {
3954 assert_eq!(
3955 approval(answer),
3956 want,
3957 "answer {answer:?} must resolve to {want:?}"
3958 );
3959 }
3960 }
3961
3962 #[tokio::test]
3963 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
3964 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3965 let mut state = run_state();
3966 state.config.graph.land_approval = true;
3967 let pr = green_pr();
3968
3969 let gate = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3970 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
3971 assert!(
3972 !state.parked,
3973 "approval_gate itself never sets `parked`; only its caller does"
3974 );
3975
3976 let store = ask::Questions::open();
3977 let filed: Vec<_> = store
3978 .list()
3979 .into_iter()
3980 .filter(|q| q.run == state.id)
3981 .collect();
3982 assert_eq!(filed.len(), 1, "exactly one question is filed");
3983 assert_eq!(filed[0].node, APPROVAL_NODE);
3984 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
3985 assert!(filed[0].status.open());
3986
3987 let again = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3991 assert_eq!(again, ApprovalGate::Pending);
3992 let still_one = store
3993 .list()
3994 .into_iter()
3995 .filter(|q| q.run == state.id)
3996 .count();
3997 assert_eq!(
3998 still_one, 1,
3999 "asking twice must not double-file the question"
4000 );
4001 }
4002
4003 #[tokio::test]
4004 async fn approving_the_existing_question_is_read_back_as_approved() {
4005 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4006 let mut state = run_state();
4007 state.config.graph.land_approval = true;
4008 let pr = green_pr();
4009 assert_eq!(
4010 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
4011 ApprovalGate::Pending
4012 );
4013
4014 let store = ask::Questions::open();
4015 let mut q = store
4016 .list()
4017 .into_iter()
4018 .find(|q| q.run == state.id)
4019 .expect("filed above");
4020 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
4021 store.put(&mut q).unwrap();
4022
4023 assert_eq!(
4024 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
4025 ApprovalGate::Approved
4026 );
4027 }
4028
4029 #[tokio::test]
4030 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
4031 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
4032 let store = ask::Questions::open();
4033
4034 let mut held_state = run_state();
4035 held_state.config.graph.land_approval = true;
4036 let pr = green_pr();
4037 approval_gate(&mut held_state, &pr, "feat: x")
4038 .await
4039 .unwrap();
4040 let mut q = store
4041 .list()
4042 .into_iter()
4043 .find(|q| q.run == held_state.id)
4044 .expect("filed above");
4045 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
4046 store.put(&mut q).unwrap();
4047 assert_eq!(
4048 approval_gate(&mut held_state, &pr, "feat: x")
4049 .await
4050 .unwrap(),
4051 ApprovalGate::Held
4052 );
4053
4054 let mut abandoned_state = run_state();
4055 abandoned_state.config.graph.land_approval = true;
4056 approval_gate(&mut abandoned_state, &pr, "feat: x")
4057 .await
4058 .unwrap();
4059 let mut q = store
4060 .list()
4061 .into_iter()
4062 .find(|q| q.run == abandoned_state.id)
4063 .expect("filed above");
4064 q.abandon("no answer within the timeout");
4065 store.put(&mut q).unwrap();
4066 assert_eq!(
4067 approval_gate(&mut abandoned_state, &pr, "feat: x")
4068 .await
4069 .unwrap(),
4070 ApprovalGate::Held,
4071 "silence must never merge"
4072 );
4073 }
4074
4075 #[test]
4076 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
4077 let rows = parse_numstat(NUMSTAT);
4078 assert_eq!(
4079 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
4080 ["src/web.rs", "src/land.rs", "assets/logo.png"]
4081 );
4082 assert_eq!(rows[2].added, None, "a binary file has no line counts");
4083 }
4084 #[test]
4085 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
4086 let mut state = run_state();
4090 state.config.graph.language = "ja".to_owned();
4091 let pr = green_pr();
4092 let commits = ["c1".to_owned()];
4093
4094 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4095 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
4096 assert!(ja.contains("squash されるコミット"), "{ja}");
4097 assert!(ja.contains("レビューコメント"), "{ja}");
4098 assert!(ja.contains("差分"), "{ja}");
4099 assert!(
4100 !ja.contains("Commits being squashed"),
4101 "no English left over"
4102 );
4103
4104 let w = words("ja");
4105 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
4106 assert!(
4107 w.approval_detail("http://x/1", "main", "feat: x")
4108 .contains("パネル")
4109 );
4110
4111 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
4113 assert!(ja.contains("feat: x"), "nor is the merge subject");
4114
4115 state.config.graph.language = "en".to_owned();
4118 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
4119 assert!(en.contains("Commits being squashed"), "{en}");
4120 assert_eq!(words("Klingon").html_lang, "en");
4121 }
4122
4123 #[test]
4127 fn pick_open_pr_classifies_by_count_and_base() {
4128 let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
4129 assert_eq!(
4130 pick_open_pr(one, "main").unwrap(),
4131 OpenPr::One {
4132 url: "https://x/pull/58".into(),
4133 title: "t".into()
4134 }
4135 );
4136 assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
4137 assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
4138 let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
4139 {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
4140 assert_eq!(
4141 pick_open_pr(two, "main").unwrap(),
4142 OpenPr::Many(vec!["u1".into(), "u2".into()])
4143 );
4144 assert!(pick_open_pr("not json", "main").is_err());
4145 assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
4147 assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
4148 }
4149
4150 #[test]
4151 fn pick_merged_pr_picks_the_unique_match() {
4152 let json = r#"[
4153 {"url": "https://github.com/o/r/pull/42", "number": 42,
4154 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
4155 ]"#;
4156 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4157 let found = pick_merged_pr(json, "main", created_at)
4158 .expect("valid json")
4159 .expect("one unambiguous match");
4160 assert_eq!(found.url, "https://github.com/o/r/pull/42");
4161 assert_eq!(found.number, 42);
4162 }
4163
4164 #[test]
4168 fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
4169 let json = r#"[
4170 {"url": "https://github.com/o/r/pull/42", "number": 42,
4171 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
4172 {"url": "https://github.com/o/r/pull/43", "number": 43,
4173 "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
4174 ]"#;
4175 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4176 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4177 }
4178
4179 #[test]
4183 fn pick_merged_pr_ignores_a_different_base_branch() {
4184 let json = r#"[
4185 {"url": "https://github.com/o/r/pull/42", "number": 42,
4186 "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
4187 ]"#;
4188 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4189 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4190 }
4191
4192 #[test]
4195 fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
4196 let json = r#"[
4197 {"url": "https://github.com/o/r/pull/42", "number": 42,
4198 "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
4199 ]"#;
4200 let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
4201 assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
4202 }
4203
4204 #[test]
4205 fn slug_of_pr_url_reads_host_owner_and_repo() {
4206 assert_eq!(
4207 slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
4208 Some("github.com/yukimemi/shun")
4209 );
4210 }
4211
4212 #[test]
4213 fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
4214 assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
4215 assert_eq!(slug_of_pr_url("not a url at all"), None);
4216 assert_eq!(slug_of_pr_url("https://github.com"), None);
4217 }
4218
4219 #[test]
4220 fn slug_of_repo_url_reads_host_owner_and_repo() {
4221 assert_eq!(
4222 slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
4223 Some("github.com/yukimemi/magi")
4224 );
4225 assert_eq!(slug_of_repo_url("https://github.com"), None);
4226 }
4227
4228 #[test]
4229 fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
4230 ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
4231 .expect("same repo, different case");
4232 }
4233
4234 #[test]
4239 fn ensure_same_repo_refuses_a_different_repo() {
4240 let err =
4241 ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
4242 let msg = format!("{err:#}");
4243 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4244 assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
4245 }
4246
4247 #[test]
4252 fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
4253 let err = ensure_same_repo(
4254 "github.com/yukimemi/magi",
4255 "github.example.com/yukimemi/magi",
4256 )
4257 .unwrap_err();
4258 let msg = format!("{err:#}");
4259 assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
4260 assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
4261 }
4262
4263 #[tokio::test]
4268 async fn find_external_merge_returns_none_without_a_winner() {
4269 let state = RunState::new(
4270 PathBuf::from("/no/such/repo"),
4271 "main".to_owned(),
4272 "0000000000000000000000000000000000000000".to_owned(),
4273 "irrelevant".to_owned(),
4274 crate::config::Config::default(),
4275 );
4276 assert_eq!(find_external_merge(&state).await.unwrap(), None);
4277 }
4278}