1use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use serde::Deserialize;
49
50use crate::agent::{self, Invocation, SeatState};
51use crate::ask;
52use crate::config::{AgentSpec, MergeMode};
53use crate::git;
54use crate::proc::Quiet as _;
55use crate::prompt;
56use crate::run::{MergeOutcome, RunState, RunStatus, tail};
57
58pub const POLL: Duration = Duration::from_secs(30);
64
65pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
71
72pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
84
85const LOG_TAIL: usize = 4_000;
88
89const MAX_LOGS: usize = 3;
92
93pub const MARKER: &str = "<!-- magi:land -->";
99
100const NOT_A_REVIEW: [&str; 3] = [
109 "skip review by coderabbit.ai",
110 "summarize by coderabbit.ai",
111 "<!-- tips_start -->",
112];
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum PrLifecycle {
117 Open,
119 Merged,
121 Closed,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Checks {
128 Pending,
130 Green,
133 Red,
135 Unknown,
137}
138
139impl PrLifecycle {
140 pub fn as_str(self) -> &'static str {
142 match self {
143 Self::Open => "open",
144 Self::Merged => "merged",
145 Self::Closed => "closed",
146 }
147 }
148}
149
150impl Checks {
151 pub fn as_str(self) -> &'static str {
153 match self {
154 Self::Pending => "pending",
155 Self::Green => "green",
156 Self::Red => "red",
157 Self::Unknown => "unknown",
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct ReviewComment {
165 pub author: String,
167 pub path: Option<String>,
169 pub line: Option<u64>,
171 pub body: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct PrState {
178 pub url: String,
180 pub number: u64,
182 pub state: PrLifecycle,
184 pub checks: Checks,
186 pub failing: Vec<String>,
188 pub review_comments: Vec<ReviewComment>,
190 pub blocking: Blocking,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum Blocking {
208 No,
210 Yes,
212 Conflict,
214 Unsaid,
218}
219
220impl Blocking {
221 fn of(raw: &str) -> Self {
223 match raw.to_ascii_uppercase().as_str() {
224 "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
227 "DIRTY" => Self::Conflict,
228 "" | "UNKNOWN" => Self::Unsaid,
229 _ => Self::Yes,
231 }
232 }
233
234 #[must_use]
236 pub fn stops_a_merge(self) -> bool {
237 !matches!(self, Self::No)
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum Step {
244 Wait,
246 Rebase,
254 Fix {
256 reason: String,
258 },
259 Merge,
261 Done {
263 merged: bool,
265 },
266 GiveUp {
268 reason: String,
270 },
271}
272
273pub(crate) fn merged_after_all(
295 argv: &[String],
296 stderr: &str,
297 after: Option<PrLifecycle>,
298) -> Option<MergeOutcome> {
299 if after? != PrLifecycle::Merged {
300 return None;
301 }
302 Some(MergeOutcome {
303 mode: MergeMode::Pr,
304 ok: true,
305 detail: format!(
306 "gh {} (the command reported `{}`, but the pull request is merged)",
307 argv.join(" "),
308 stderr.trim()
309 ),
310 })
311}
312
313pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
333 match pr.state {
334 PrLifecycle::Merged => return Step::Done { merged: true },
335 PrLifecycle::Closed => return Step::Done { merged: false },
336 PrLifecycle::Open => {}
337 }
338
339 if pr.blocking == Blocking::Conflict {
342 return Step::Rebase;
343 }
344
345 let spent = round >= budget;
346 match pr.checks {
347 Checks::Pending => Step::Wait,
348 Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
349 Checks::Unknown => Step::GiveUp {
350 reason: format!(
351 "no check status is readable on the pull request after {} minute(s); \
352 refusing to merge on a guess",
353 CHECKS_GRACE.as_secs() / 60
354 ),
355 },
356 Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
363 Checks::Red => {
364 let what = format!(
365 "{} check(s) failing: {}",
366 pr.failing.len(),
367 pr.failing.join(", ")
368 );
369 if spent {
370 Step::GiveUp {
371 reason: format!("{what} — still red after {budget} fix round(s)"),
372 }
373 } else {
374 Step::Fix { reason: what }
375 }
376 }
377 Checks::Green if pr.review_comments.is_empty() => Step::Merge,
378 Checks::Green => {
379 let what = format!(
380 "checks are green but {} review comment(s) are unresolved: {}",
381 pr.review_comments.len(),
382 authors(&pr.review_comments)
383 );
384 if spent {
385 Step::GiveUp {
386 reason: format!("{what} — still unresolved after {budget} fix round(s)"),
387 }
388 } else {
389 Step::Fix { reason: what }
390 }
391 }
392 }
393}
394
395fn authors(comments: &[ReviewComment]) -> String {
397 let mut seen: Vec<&str> = Vec::new();
398 for c in comments {
399 if !seen.contains(&c.author.as_str()) {
400 seen.push(&c.author);
401 }
402 }
403 seen.join(", ")
404}
405
406pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
410 vec![
411 "pr".to_owned(),
412 "merge".to_owned(),
413 number.to_string(),
414 "--squash".to_owned(),
415 "--delete-branch".to_owned(),
416 "--subject".to_owned(),
417 subject.to_owned(),
418 ]
419}
420
421pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
428 let title = pr_title.trim();
429 if !title.is_empty() && !title.starts_with("magi: candidate") {
430 return title.to_owned();
431 }
432 let first = instruction
433 .lines()
434 .map(str::trim)
435 .find(|l| !l.is_empty())
436 .unwrap_or("magi: land the winning candidate");
437 first.trim_start_matches(['#', ' ']).to_owned()
438}
439
440pub const APPROVE: &str = "merge";
442
443pub const HOLD: &str = "hold";
445
446pub const APPROVAL_NODE: &str = "land-approval";
452
453pub const DIFF_MAX_LINES: usize = 400;
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Approval {
465 Merge,
467 Hold,
469}
470
471pub fn approval(answer: Option<&str>) -> Approval {
479 match answer {
480 Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
481 _ => Approval::Hold,
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487enum ApprovalGate {
488 Approved,
490 Held,
493 Pending,
495}
496
497fn esc(s: &str) -> String {
507 let mut out = String::with_capacity(s.len());
508 for c in s.chars() {
509 match c {
510 '&' => out.push_str("&"),
511 '<' => out.push_str("<"),
512 '>' => out.push_str(">"),
513 '"' => out.push_str("""),
514 '\'' => out.push_str("'"),
515 _ => out.push(c),
516 }
517 }
518 out
519}
520
521#[derive(Debug, Clone, PartialEq, Eq)]
523struct StatRow {
524 path: String,
525 added: Option<u64>,
527 removed: Option<u64>,
528}
529
530impl StatRow {
531 fn churn(&self) -> u64 {
534 self.added.unwrap_or(0) + self.removed.unwrap_or(0)
535 }
536}
537
538fn parse_numstat(numstat: &str) -> Vec<StatRow> {
544 let mut rows: Vec<StatRow> = numstat
545 .lines()
546 .filter_map(|line| {
547 let mut parts = line.splitn(3, '\t');
548 let added = parts.next()?.trim();
549 let removed = parts.next()?.trim();
550 let path = parts.next()?.trim();
551 if path.is_empty() {
552 return None;
553 }
554 Some(StatRow {
555 path: path.to_owned(),
556 added: added.parse().ok(),
557 removed: removed.parse().ok(),
558 })
559 })
560 .collect();
561 rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
564 rows
565}
566
567fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
576 if line.starts_with("+++") || line.starts_with("---") {
577 (" ", "color:#57606a;font-weight:600", line)
578 } else if let Some(body) = line.strip_prefix('+') {
579 ("+", "background:#e6ffec;color:#0a3622", body)
580 } else if let Some(body) = line.strip_prefix('-') {
581 ("-", "background:#ffebe9;color:#5c1a17", body)
582 } else if line.starts_with("@@") {
583 ("~", "background:#eef2ff;color:#3730a3", line)
584 } else if let Some(body) = line.strip_prefix(' ') {
585 (" ", "", body)
586 } else {
587 (" ", "color:#57606a;font-weight:600", line)
588 }
589}
590
591struct Words {
600 html_lang: &'static str,
601 checks: &'static str,
602 nothing_failing: &'static str,
603 files_changed: &'static str,
604 commits: &'static str,
605 no_commits: &'static str,
606 comments: &'static str,
607 no_comments: &'static str,
608 diff: &'static str,
609 truncated: &'static str,
610 lands_as: &'static str,
611}
612
613const EN: Words = Words {
614 html_lang: "en",
615 checks: "Checks",
616 nothing_failing: "Nothing failing.",
617 files_changed: "file(s) changed",
618 commits: "Commits being squashed",
619 no_commits: "No commit subjects could be read from the branch.",
620 comments: "Review comments",
621 no_comments: "Nothing outstanding at this observation.",
622 diff: "Diff",
623 truncated: "Truncated",
624 lands_as: "They land as one commit titled",
625};
626
627const JA: Words = Words {
628 html_lang: "ja",
629 checks: "チェック",
630 nothing_failing: "失敗しているものはありません。",
631 files_changed: "ファイル変更",
632 commits: "squash されるコミット",
633 no_commits: "ブランチからコミット件名を読めませんでした。",
634 comments: "レビューコメント",
635 no_comments: "この時点で未対応のものはありません。",
636 diff: "差分",
637 truncated: "省略",
638 lands_as: "これらは次の件名の1コミットとして入ります:",
639};
640
641impl Words {
642 fn lands_as_tail(&self) -> &'static str {
646 if self.html_lang == "ja" {
647 "。この件名も承認の対象です。"
648 } else {
649 ", which you are approving too."
650 }
651 }
652
653 fn approval_summary(&self, number: u64, subject: &str) -> String {
655 if self.html_lang == "ja" {
656 format!("プルリクエスト #{number} をマージ: {subject}")
657 } else {
658 format!("merge pull request #{number}: {subject}")
659 }
660 }
661
662 fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
664 if self.html_lang == "ja" {
665 format!(
666 "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
667 できる状態です。差分の要約・パッチ・squash されるコミットは\
668 下のパネルにあります。"
669 )
670 } else {
671 format!(
672 "{url} is green and ready to squash into `{base}` as `{subject}`. \
673 The panel holds the diffstat, the patch and the commits being squashed."
674 )
675 }
676 }
677
678 fn truncated_note(
680 &self,
681 omitted: usize,
682 total: usize,
683 shown: usize,
684 where_: &str,
685 base: &str,
686 head: &str,
687 ) -> String {
688 if self.html_lang == "ja" {
689 format!(
690 "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
691 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
692 プルリクエストにあります。"
693 )
694 } else {
695 format!(
696 "{omitted} of {total} diff lines omitted after the first {shown}. \
697 The whole patch is in <code>{where_}</code> \
698 (<code>git diff {base}...{head}</code>) and on the pull request."
699 )
700 }
701 }
702}
703
704fn words(language: &str) -> &'static Words {
707 let l = language.trim();
708 if l.eq_ignore_ascii_case("ja")
709 || l.eq_ignore_ascii_case("jp")
710 || l.eq_ignore_ascii_case("japanese")
711 || l.eq_ignore_ascii_case("日本語")
712 {
713 &JA
714 } else {
715 &EN
716 }
717}
718
719pub fn approval_panel(
731 state: &RunState,
732 pr: &PrState,
733 diffstat: &str,
734 diff: &str,
735 commits: &[String],
736 subject: &str,
737) -> String {
738 let rows = parse_numstat(diffstat);
739 let w = words(&state.config.graph.language);
740 let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
741
742 let _ = writeln!(
743 h,
744 "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
745 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
746 w.html_lang
747 );
748 let _ = writeln!(
749 h,
750 "<title>merge #{} — {}</title>\n</head>",
751 pr.number,
752 esc(subject)
753 );
754 h.push_str(
755 "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
756 'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
757 word-break:break-word\">\n",
758 );
759
760 let _ = writeln!(
762 h,
763 "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
764 <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
765 <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
766 <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
767 <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
768 pr.number,
769 esc(&state.base_branch),
770 esc(subject),
771 esc(&state.id),
772 esc(&pr.url),
773 esc(&pr.url),
774 );
775
776 let _ = writeln!(
777 h,
778 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
779 w.checks,
780 esc(pr.checks.as_str())
781 );
782 if pr.failing.is_empty() {
783 let _ = writeln!(
784 h,
785 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
786 w.nothing_failing
787 );
788 } else {
789 h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
790 for f in &pr.failing {
791 let _ = writeln!(h, "<li>{}</li>", esc(f));
792 }
793 h.push_str("</ul>\n");
794 }
795
796 let _ = writeln!(
799 h,
800 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
801 rows.len(),
802 w.files_changed
803 );
804 h.push_str(
805 "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
806 <thead><tr>\
807 <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
808 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
809 <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
810 </th></tr></thead>\n<tbody>\n",
811 );
812 let mut total_added = 0u64;
813 let mut total_removed = 0u64;
814 for r in &rows {
815 total_added += r.added.unwrap_or(0);
816 total_removed += r.removed.unwrap_or(0);
817 let cell = |n: Option<u64>| match n {
818 Some(n) => n.to_string(),
819 None => "bin".to_owned(),
820 };
821 let _ = writeln!(
822 h,
823 "<tr>\
824 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
825 font-family:ui-monospace,monospace\">{}</td>\
826 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
827 color:#0a3622\">{}</td>\
828 <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
829 color:#5c1a17\">{}</td></tr>",
830 esc(&r.path),
831 cell(r.added),
832 cell(r.removed),
833 );
834 }
835 let _ = writeln!(
836 h,
837 "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
838 <td style=\"padding:4px 2px\">total</td>\
839 <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
840 <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
841 </tr></tfoot>\n</table>"
842 );
843
844 let _ = writeln!(
846 h,
847 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
848 w.commits
849 );
850 if commits.is_empty() {
851 h.push_str(&format!(
852 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
853 w.no_commits
854 ));
855 } else {
856 h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
857 for c in commits {
858 let _ = writeln!(h, "<li>{}</li>", esc(c));
859 }
860 h.push_str("</ol>\n");
861 }
862 let _ = writeln!(
863 h,
864 "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
865 w.lands_as,
866 esc(subject),
867 w.lands_as_tail()
868 );
869
870 let _ = writeln!(
872 h,
873 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
874 w.comments
875 );
876 if pr.review_comments.is_empty() {
877 h.push_str(&format!(
878 "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
879 w.no_comments
880 ));
881 } else {
882 for c in &pr.review_comments {
883 let anchor = match (&c.path, c.line) {
884 (Some(p), Some(l)) => format!("{p}:{l}"),
885 (Some(p), None) => p.clone(),
886 _ => "pull request thread".to_owned(),
887 };
888 let _ = writeln!(
889 h,
890 "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
891 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
892 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
893 esc(&c.author),
894 esc(&anchor),
895 esc(&tail(&c.body, 800)),
896 );
897 }
898 }
899
900 let total = diff.lines().count();
902 let shown = total.min(DIFF_MAX_LINES);
903 let _ = writeln!(
904 h,
905 "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
906 w.diff
907 );
908 h.push_str(
909 "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
910 border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
911 );
912 for line in diff.lines().take(shown) {
913 let (gutter, style, body) = diff_row(line);
914 let _ = writeln!(
915 h,
916 "<div style=\"display:flex;{style}\">\
917 <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
918 border-right:1px solid #d0d7de\">{gutter}</span>\
919 <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
920 esc(body),
921 );
922 }
923 h.push_str("</div>\n");
924 if total > shown {
925 let omitted = total - shown;
926 let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
927 let where_ = state.winner().map_or_else(
928 || state.repo.display().to_string(),
929 |w| w.worktree.display().to_string(),
930 );
931 let _ = writeln!(
932 h,
933 "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
934 font-size:13px\">{}: {}</p>",
935 w.truncated,
936 w.truncated_note(
937 omitted,
938 total,
939 shown,
940 &esc(&where_),
941 &esc(&state.base_branch),
942 &esc(head),
943 ),
944 );
945 }
946
947 h.push_str("</body>\n</html>\n");
948 h
949}
950
951async fn approval_gate(state: &mut RunState, pr: &PrState, subject: &str) -> Result<ApprovalGate> {
971 let store = ask::Questions::open();
972 let existing = store
973 .list()
974 .into_iter()
975 .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
976 .max_by(|a, b| a.id.cmp(&b.id));
977
978 let q = match existing {
979 Some(q) => q,
980 None => {
981 let (worktree, head) = match state.winner() {
982 Some(w) => (w.worktree.clone(), w.branch.clone()),
983 None => (state.repo.clone(), "HEAD".to_owned()),
984 };
985 let base = state.base_branch.clone();
986 let range = format!("{base}...{head}");
987 let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
991 .await
992 .map(|o| o.stdout)
993 .unwrap_or_default();
994 let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
995 let commits: Vec<String> = git::git_raw(
996 &worktree,
997 &[
998 "log",
999 "--reverse",
1000 "--format=%s",
1001 &format!("{base}..{head}"),
1002 ],
1003 )
1004 .await
1005 .map(|o| o.stdout)
1006 .unwrap_or_default()
1007 .lines()
1008 .filter(|l| !l.trim().is_empty())
1009 .map(str::to_owned)
1010 .collect();
1011
1012 let w = words(&state.config.graph.language);
1013 let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1014 let mut fresh = ask::Question::new(
1015 state.id.clone(),
1016 APPROVAL_NODE.to_owned(),
1017 "land".to_owned(),
1018 w.approval_summary(pr.number, subject),
1019 w.approval_detail(&pr.url, &base, subject),
1020 vec![APPROVE.to_owned(), HOLD.to_owned()],
1021 );
1022 store
1023 .put_panel(&mut fresh, &html, &[])
1024 .context("write the merge approval panel")?;
1025 store
1026 .put(&mut fresh)
1027 .context("file the merge approval question")?;
1028 state.event(
1029 "land",
1030 format!("asking for merge approval ({})", fresh.short()),
1031 );
1032 state.save()?;
1033 if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1034 tracing::warn!(
1038 "could not notify about merge approval question {}: {e:#} - \
1039 the web UI is the only surface for it now",
1040 fresh.short()
1041 );
1042 }
1043 fresh
1044 }
1045 };
1046
1047 Ok(match q.status {
1048 ask::QuestionStatus::Open => ApprovalGate::Pending,
1049 ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1053 ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1057 Approval::Merge => ApprovalGate::Approved,
1058 Approval::Hold => ApprovalGate::Held,
1059 },
1060 })
1061}
1062
1063pub fn parse_pr(json: &str) -> Result<PrState> {
1066 let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1067 let state = match raw.state.to_ascii_uppercase().as_str() {
1068 "OPEN" => PrLifecycle::Open,
1069 "MERGED" => PrLifecycle::Merged,
1070 "CLOSED" => PrLifecycle::Closed,
1071 other => bail!("unknown pull request state `{other}`"),
1072 };
1073
1074 let mut failing = Vec::new();
1075 let mut pending = false;
1076 let mut unknown = false;
1077 for check in &raw.status_check_rollup {
1078 match check.verdict() {
1079 Verdict::Pass => {}
1080 Verdict::Pending => pending = true,
1081 Verdict::Fail => failing.push(check.label()),
1082 Verdict::Unknown => unknown = true,
1083 }
1084 }
1085 let checks = if raw.status_check_rollup.is_empty() {
1086 Checks::Unknown
1087 } else if pending {
1088 Checks::Pending
1089 } else if !failing.is_empty() {
1090 Checks::Red
1091 } else if unknown {
1092 Checks::Unknown
1093 } else {
1094 Checks::Green
1095 };
1096
1097 let mut review_comments = Vec::new();
1098 for r in raw.reviews {
1099 push_if_outstanding(
1100 &mut review_comments,
1101 ReviewComment {
1102 author: r.author.login,
1103 path: None,
1104 line: None,
1105 body: r.body,
1106 },
1107 );
1108 }
1109 for c in raw.comments {
1110 push_if_outstanding(
1111 &mut review_comments,
1112 ReviewComment {
1113 author: c.author.login,
1114 path: None,
1115 line: None,
1116 body: c.body,
1117 },
1118 );
1119 }
1120
1121 Ok(PrState {
1122 url: raw.url,
1123 number: raw.number,
1124 state,
1125 checks,
1126 failing,
1127 review_comments,
1128 blocking: Blocking::of(&raw.merge_state_status),
1129 })
1130}
1131
1132pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1139 let raw: Vec<GhInline> =
1140 serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1141 let mut out = Vec::new();
1142 for c in raw {
1143 push_if_outstanding(
1144 &mut out,
1145 ReviewComment {
1146 author: c.user.login,
1147 path: c.path,
1148 line: c.line,
1149 body: c.body,
1150 },
1151 );
1152 }
1153 Ok(out)
1154}
1155
1156fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1162 if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1163 return;
1164 }
1165 if comment.path.is_none() && is_noise(&comment.body) {
1166 return;
1167 }
1168 out.push(comment);
1169}
1170
1171pub fn is_noise(body: &str) -> bool {
1189 if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1190 return true;
1191 }
1192 let mut content = false;
1193 for line in strip_blocks(body).lines() {
1194 let line = unquote(line);
1195 if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1196 continue;
1197 }
1198 content = true;
1199 break;
1200 }
1201 !content
1202}
1203
1204fn strip_blocks(body: &str) -> String {
1206 let mut out = String::with_capacity(body.len());
1207 let mut rest = body;
1208 loop {
1209 let open = ["<!--", "<details>"]
1210 .iter()
1211 .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1212 .min_by_key(|(i, _)| *i);
1213 let Some((at, tag)) = open else {
1214 out.push_str(rest);
1215 return out;
1216 };
1217 out.push_str(&rest[..at]);
1218 let after = &rest[at + tag.len()..];
1219 let close = if tag == "<!--" { "-->" } else { "</details>" };
1220 match after.find(close) {
1221 Some(end) => rest = &after[end + close.len()..],
1222 None => return out,
1224 }
1225 }
1226}
1227
1228fn unquote(line: &str) -> &str {
1230 let mut s = line.trim();
1231 while let Some(rest) = s.strip_prefix('>') {
1232 s = rest.trim_start();
1233 }
1234 s.trim()
1235}
1236
1237fn is_checklist(line: &str) -> bool {
1239 let rest = line
1240 .strip_prefix("- ")
1241 .or_else(|| line.strip_prefix("* "))
1242 .unwrap_or("");
1243 let rest = rest.trim_start();
1244 matches!(
1245 rest.get(..3),
1246 Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1247 )
1248}
1249
1250fn is_decoration(line: &str) -> bool {
1252 line.starts_with('#')
1253 || line.starts_with("[!")
1254 || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1255}
1256
1257fn is_banner(line: &str) -> bool {
1264 let plain = drop_spans(line, "**", "**");
1265 let plain = if plain.contains("](") {
1266 drop_spans(&plain, "[", ")")
1267 } else {
1268 plain
1269 };
1270 !plain.chars().any(char::is_alphanumeric)
1271}
1272
1273fn drop_spans(s: &str, open: &str, close: &str) -> String {
1277 let mut out = String::with_capacity(s.len());
1278 let mut rest = s;
1279 while let Some(at) = rest.find(open) {
1280 out.push_str(&rest[..at]);
1281 let after = &rest[at + open.len()..];
1282 match after.find(close) {
1283 Some(end) => rest = &after[end + close.len()..],
1284 None => return out,
1285 }
1286 }
1287 out.push_str(rest);
1288 out
1289}
1290
1291fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
1309 static LOCKS: std::sync::LazyLock<
1310 std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
1311 > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
1312 LOCKS
1313 .lock()
1314 .unwrap_or_else(std::sync::PoisonError::into_inner)
1315 .entry(repo.to_path_buf())
1316 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1317 .clone()
1318}
1319
1320pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1327 let repo = state.repo.clone();
1328 let budget = state.config.graph.land_rounds;
1329 let mut round = 0usize;
1330 let mut rebases = 0usize;
1333 let mut waited = Duration::ZERO;
1334 let mut shown: BTreeSet<String> = BTreeSet::new();
1339
1340 state.status = RunStatus::Landing;
1348 state.event("land", format!("watching {pr_url}"));
1349 state.save()?;
1350
1351 loop {
1352 let seen = observe(&repo, pr_url).await?;
1353 let mut pr = seen.pr;
1354 pr.review_comments.retain(|c| !shown.contains(&c.body));
1355 state.pr = Some(crate::run::PrRecord {
1356 url: pr.url.clone(),
1357 number: pr.number,
1358 state: pr.state.as_str().to_owned(),
1359 checks: pr.checks.as_str().to_owned(),
1360 round,
1361 rounds: budget,
1362 });
1363 state.save()?;
1364
1365 match decide(&pr, round, budget, waited) {
1366 Step::Wait => {
1367 if waited >= WAIT_CEILING {
1368 let why = format!(
1369 "checks were still running after {} minutes",
1370 WAIT_CEILING.as_secs() / 60
1371 );
1372 stop(state, &repo, &pr, &why).await?;
1373 return Ok(pr);
1374 }
1375 waited += POLL;
1376 tokio::time::sleep(POLL).await;
1377 }
1378 Step::Done { merged } => {
1379 state.status = if merged {
1380 RunStatus::Merged
1381 } else {
1382 RunStatus::Ready
1383 };
1384 let detail = if merged {
1385 format!("{} was merged", pr.url)
1386 } else {
1387 format!("{} was closed without merging", pr.url)
1388 };
1389 state.merge = Some(MergeOutcome {
1390 mode: MergeMode::Pr,
1391 ok: merged,
1392 detail: detail.clone(),
1393 });
1394 state.event("land", detail);
1395 state.save()?;
1396 return Ok(pr);
1397 }
1398 Step::Merge => {
1399 let subject = merge_subject(&seen.title, &state.instruction);
1400 if state.config.graph.land_approval {
1403 match approval_gate(state, &pr, &subject).await? {
1404 ApprovalGate::Approved => {}
1405 ApprovalGate::Held => {
1406 stop(
1407 state,
1408 &repo,
1409 &pr,
1410 "the owner did not approve the merge (held or unanswered)",
1411 )
1412 .await?;
1413 return Ok(pr);
1414 }
1415 ApprovalGate::Pending => {
1423 state.parked = true;
1424 state.event(
1425 "land",
1426 "parked awaiting merge approval - resumes once answered",
1427 );
1428 state.save()?;
1429 return Ok(pr);
1430 }
1431 }
1432 }
1433 let argv = merge_argv(pr.number, &subject);
1434 let out = {
1435 let merge_lock = repo_merge_lock(&repo);
1436 let _merge_slot = merge_lock.lock().await;
1437 gh(&repo, &argv).await?
1438 };
1439 if out.0 {
1440 state.status = RunStatus::Merged;
1441 state.merge = Some(MergeOutcome {
1442 mode: MergeMode::Pr,
1443 ok: true,
1444 detail: format!("gh {}", argv.join(" ")),
1445 });
1446 state.event("land", format!("merged {} as `{subject}`", pr.url));
1447 state.save()?;
1448 pr.state = PrLifecycle::Merged;
1449 return Ok(pr);
1450 }
1451 let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
1452 if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
1453 state.status = RunStatus::Merged;
1454 state.merge = Some(outcome);
1455 state.event("land", format!("merged {} as `{subject}`", pr.url));
1456 state.save()?;
1457 pr.state = PrLifecycle::Merged;
1458 return Ok(pr);
1459 }
1460 stop(
1461 state,
1462 &repo,
1463 &pr,
1464 &format!("`gh pr merge` failed: {}", out.1),
1465 )
1466 .await?;
1467 return Ok(pr);
1468 }
1469 Step::Rebase => {
1470 if rebases >= budget {
1476 let why = format!(
1477 "the base moved under this branch {budget} time(s) and it still does \
1478 not merge; rebasing again would only race it"
1479 );
1480 stop(state, &repo, &pr, &why).await?;
1481 return Ok(pr);
1482 }
1483 rebases += 1;
1484 let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
1485 stop(
1486 state,
1487 &repo,
1488 &pr,
1489 "the pull request conflicts and this run has no winning branch to rebase",
1490 )
1491 .await?;
1492 return Ok(pr);
1493 };
1494 let base = state.base_branch.clone();
1495 state.event(
1496 "land",
1497 format!("{} no longer merges; rebasing onto {base}", pr.url),
1498 );
1499 state.save()?;
1500
1501 git::fetch(&repo, "origin", &base).await.ok();
1505 let scratch = state.dir().join("rebase");
1506 let onto = format!("origin/{base}");
1507 match git::rebase_branch_in_temp(&repo, &scratch, &branch, &onto).await {
1508 Ok(None) => {
1509 let pushed = {
1510 let merge_lock = repo_merge_lock(&repo);
1511 let _merge_slot = merge_lock.lock().await;
1512 git::push_rewritten(&repo, "origin", &branch).await?
1513 };
1514 if !pushed.ok() {
1515 let why = format!(
1516 "rebased {branch} but could not push it: {}",
1517 pushed.stderr.trim()
1518 );
1519 stop(state, &repo, &pr, &why).await?;
1520 return Ok(pr);
1521 }
1522 state.event("land", format!("rebased {branch} onto {base}"));
1523 state.save()?;
1524 waited = Duration::ZERO;
1527 tokio::time::sleep(POLL).await;
1528 }
1529 Ok(Some(conflict)) => {
1531 let why = format!(
1532 "{} conflicts with {base} and the rebase did not apply: {}",
1533 pr.url,
1534 conflict.chars().take(600).collect::<String>()
1535 );
1536 stop(state, &repo, &pr, &why).await?;
1537 return Ok(pr);
1538 }
1539 Err(e) => {
1540 let why = format!("could not rebase {branch} onto {base}: {e:#}");
1541 stop(state, &repo, &pr, &why).await?;
1542 return Ok(pr);
1543 }
1544 }
1545 }
1546 Step::GiveUp { reason } => {
1547 stop(state, &repo, &pr, &reason).await?;
1548 return Ok(pr);
1549 }
1550 Step::Fix { reason } => {
1551 round += 1;
1552 waited = Duration::ZERO;
1553 for c in &pr.review_comments {
1554 shown.insert(c.body.clone());
1555 }
1556 state.event("land", format!("round {round}: {reason}"));
1557 state.save()?;
1558
1559 let logs = failing_logs(&repo, &seen.failing_urls).await;
1560 let was_red = pr.checks == Checks::Red;
1561 match fix_round(state, &pr, round, budget, &reason, &logs).await? {
1562 Fixed::Committed => {}
1563 Fixed::Declined if was_red => {
1564 let why = format!(
1565 "the fixer produced no commit while {} check(s) were failing; \
1566 stopping instead of looping on an unchanged tree",
1567 pr.failing.len()
1568 );
1569 stop(state, &repo, &pr, &why).await?;
1570 return Ok(pr);
1571 }
1572 Fixed::Declined => state.event(
1577 "land",
1578 format!("round {round}: fixer declined the comments, nothing committed"),
1579 ),
1580 Fixed::Failed(why) => {
1581 stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
1582 return Ok(pr);
1583 }
1584 }
1585 state.save()?;
1586 }
1587 }
1588 }
1589}
1590
1591struct Seen {
1595 pr: PrState,
1596 title: String,
1597 failing_urls: Vec<(String, String)>,
1598}
1599
1600async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
1603 let view = gh(
1604 repo,
1605 &[
1606 "pr".to_owned(),
1607 "view".to_owned(),
1608 pr_url.to_owned(),
1609 "--json".to_owned(),
1610 "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
1611 ],
1612 )
1613 .await?;
1614 if !view.0 {
1615 bail!("gh pr view {pr_url}: {}", view.1);
1616 }
1617 let mut pr = parse_pr(&view.1)?;
1618 let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
1619
1620 let inline = gh(
1621 repo,
1622 &[
1623 "api".to_owned(),
1624 format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
1625 ],
1626 )
1627 .await?;
1628 if inline.0 {
1629 match parse_inline_comments(&inline.1) {
1630 Ok(mut comments) => pr.review_comments.append(&mut comments),
1631 Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
1634 }
1635 } else {
1636 tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
1637 }
1638
1639 let failing_urls = raw
1640 .status_check_rollup
1641 .iter()
1642 .filter(|c| c.verdict() == Verdict::Fail)
1643 .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
1644 .collect();
1645
1646 Ok(Seen {
1647 pr,
1648 title: raw.title,
1649 failing_urls,
1650 })
1651}
1652
1653enum Fixed {
1655 Committed,
1657 Declined,
1659 Failed(String),
1661}
1662
1663async fn fix_round(
1669 state: &mut RunState,
1670 pr: &PrState,
1671 round: usize,
1672 budget: usize,
1673 reason: &str,
1674 logs: &str,
1675) -> Result<Fixed> {
1676 let winner = state
1677 .winner()
1678 .cloned()
1679 .context("landing needs a winning candidate; none is recorded on this run")?;
1680 let roles = state
1681 .config
1682 .resolve_roles()
1683 .context("resolve the roster for the fix round")?;
1684 let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
1688 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1689 _ => (
1690 state
1691 .config
1692 .agent(&winner.agent)
1693 .cloned()
1694 .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
1695 format!("impl-{}", winner.label),
1696 ),
1697 };
1698
1699 let prompt = fix_prompt(state, pr, round, budget, reason, logs);
1700 let mut seat = seat_of(state, &seat_key, &spec.id);
1701 let artifacts = agent::artifacts_dir(&state.dir());
1702 let prompt = if state.config.cache_dir().is_some() {
1703 format!("{prompt}\n\n{}", prompt::build_cache_note())
1704 } else {
1705 prompt
1706 };
1707 let out = agent::invoke(
1708 &spec,
1709 &mut seat,
1710 &Invocation {
1711 cwd: &winner.worktree,
1712 prompt: &prompt,
1713 timeout: Duration::from_secs(state.config.graph.timeout_fix),
1714 allow_write: true,
1715 sessions: state.config.graph.sessions,
1716 artifacts: &artifacts,
1717 stem: &format!("land-{round}"),
1718 run: &state.id,
1719 node: "land",
1720 cache_dir: state.config.cache_dir().as_deref(),
1721 },
1722 )
1723 .await;
1724 state.seats.insert(seat.key.clone(), seat);
1725
1726 match out {
1727 Ok(o) if o.quota_exhausted() => {
1728 return Ok(Fixed::Failed(
1729 "rate limited (quota); the fixer could not run".to_owned(),
1730 ));
1731 }
1732 Ok(o) if !o.usable() => {
1733 return Ok(Fixed::Failed(format!(
1734 "the fixer produced nothing usable (exit {:?}, timed out: {})",
1735 o.exit_code, o.timed_out
1736 )));
1737 }
1738 Ok(_) => {}
1739 Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
1740 }
1741
1742 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1743 git::commit_all(
1746 &winner.worktree,
1747 &format!("magi: land round {round} fixes (uncommitted work)"),
1748 )
1749 .await
1750 .ok();
1751 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
1752 if after == before {
1753 return Ok(Fixed::Declined);
1754 }
1755
1756 let remote = state.config.merge.remote.clone();
1757 let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
1758 if !push.ok() {
1759 return Ok(Fixed::Failed(format!(
1760 "pushing {} to {remote} failed: {}",
1761 winner.branch, push.stderr
1762 )));
1763 }
1764 state.event(
1765 "land",
1766 format!("round {round}: pushed a fix to {}", winner.branch),
1767 );
1768 Ok(Fixed::Committed)
1769}
1770
1771fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
1773 if let Some(existing) = state.seats.get(key)
1774 && existing.agent == agent
1775 {
1776 return existing.clone();
1777 }
1778 let fresh = SeatState::new(key, agent, state.seed);
1779 state.seats.insert(key.to_owned(), fresh.clone());
1780 fresh
1781}
1782
1783fn fix_prompt(
1785 state: &RunState,
1786 pr: &PrState,
1787 round: usize,
1788 budget: usize,
1789 reason: &str,
1790 logs: &str,
1791) -> String {
1792 let mut s = format!(
1793 "Your patch is open as a pull request and it is not landing. Land round \
1794 {round} of {budget}.\n\n\
1795 Pull request: {}\n\n\
1796 What is holding it: {reason}\n\n\
1797 # The task\n\n{}\n",
1798 pr.url, state.instruction
1799 );
1800
1801 if pr.failing.is_empty() {
1802 s.push_str("\n# Failing checks\n\n(none)\n");
1803 } else {
1804 let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
1805 if logs.trim().is_empty() {
1806 s.push_str("\nNo log could be read; reproduce the failure locally.\n");
1807 } else {
1808 let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
1809 }
1810 }
1811
1812 if pr.review_comments.is_empty() {
1813 s.push_str("\n# Review comments\n\n(none)\n");
1814 } else {
1815 s.push_str("\n# Review comments\n");
1816 for c in &pr.review_comments {
1817 let where_ = match (&c.path, c.line) {
1818 (Some(p), Some(l)) => format!(" ({p}:{l})"),
1819 (Some(p), None) => format!(" ({p})"),
1820 _ => String::new(),
1821 };
1822 let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
1823 }
1824 }
1825
1826 s.push_str(
1827 "\n# Rules\n\n\
1828 1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
1829 failing test; do not silence a lint with an allow attribute; do not \
1830 stretch a timeout to hide a race. If the check is right, the code is \
1831 wrong.\n\
1832 2. Change nothing the checks and the comments did not raise. A \
1833 drive-by refactor turns a one-line fix into a pull request that \
1834 needs reviewing again.\n\
1835 3. If a comment is wrong, say so with a checkable argument and change \
1836 nothing for it. A declined comment with a reason is a correct \
1837 outcome; a change made to appease a reviewer is not.\n\
1838 4. Commit in this worktree. magi pushes to the pull request's branch \
1839 for you; do not push, merge, or close anything yourself.\n\
1840 5. Never name yourself, your vendor, or your model, anywhere.\n\n\
1841 # Output\n\n\
1842 Say what you changed and why, and what you declined and why.",
1843 );
1844
1845 let language = &state.config.graph.language;
1846 if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
1847 let _ = write!(s, "\n\nWrite all prose in {language}.");
1848 }
1849 if let Some(overlay) = state.config.prompts.overlay("fix") {
1850 let _ = write!(s, "\n\n{overlay}");
1851 }
1852 s
1853}
1854
1855async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
1858 let mut out = String::new();
1859 for (name, url) in failing.iter().take(MAX_LOGS) {
1860 let args = match (job_of(url), run_of(url)) {
1861 (Some(job), _) => vec![
1862 "run".to_owned(),
1863 "view".to_owned(),
1864 "--log-failed".to_owned(),
1865 "--job".to_owned(),
1866 job,
1867 ],
1868 (None, Some(run)) => vec![
1869 "run".to_owned(),
1870 "view".to_owned(),
1871 run,
1872 "--log-failed".to_owned(),
1873 ],
1874 (None, None) => continue,
1876 };
1877 let (ok, body) = match gh(repo, &args).await {
1878 Ok(v) => v,
1879 Err(e) => (false, format!("{e:#}")),
1880 };
1881 if !ok && body.trim().is_empty() {
1882 continue;
1883 }
1884 let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
1885 }
1886 out
1887}
1888
1889fn job_of(details_url: &str) -> Option<String> {
1892 let after = details_url.split("/job/").nth(1)?;
1893 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1894 (!id.is_empty()).then_some(id)
1895}
1896
1897fn run_of(details_url: &str) -> Option<String> {
1899 let after = details_url.split("/actions/runs/").nth(1)?;
1900 let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1901 (!id.is_empty()).then_some(id)
1902}
1903
1904async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
1909 let body = format!(
1910 "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
1911 The branch is untouched and the run is `{}`. Nothing was merged.",
1912 state.id
1913 );
1914 let posted = gh(
1915 repo,
1916 &[
1917 "pr".to_owned(),
1918 "comment".to_owned(),
1919 pr.number.to_string(),
1920 "--body".to_owned(),
1921 body,
1922 ],
1923 )
1924 .await;
1925 match posted {
1926 Ok((true, _)) => {}
1927 Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
1928 Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
1929 }
1930 state.status = RunStatus::Blocked;
1931 state.merge = Some(MergeOutcome {
1932 mode: MergeMode::Pr,
1933 ok: false,
1934 detail: why.to_owned(),
1935 });
1936 state.event("land", format!("stopped: {why}"));
1937 state.save()?;
1938 Ok(())
1939}
1940
1941async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
1946 let out = tokio::process::Command::new("gh")
1947 .args(args)
1948 .current_dir(cwd)
1949 .quiet()
1950 .stdin(std::process::Stdio::null())
1951 .output()
1952 .await
1953 .with_context(|| format!("spawn gh {}", args.join(" ")))?;
1954 let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
1955 let err = String::from_utf8_lossy(&out.stderr);
1956 if body.trim().is_empty() {
1957 body = err.into_owned();
1958 } else if !err.trim().is_empty() {
1959 body.push_str(&err);
1960 }
1961 Ok((out.status.success(), body.trim().to_owned()))
1962}
1963
1964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1966enum Verdict {
1967 Pass,
1968 Fail,
1969 Pending,
1970 Unknown,
1971}
1972
1973#[derive(Debug, Deserialize)]
1974#[serde(rename_all = "camelCase")]
1975struct GhPr {
1976 #[serde(default)]
1977 url: String,
1978 #[serde(default)]
1979 number: u64,
1980 #[serde(default)]
1981 state: String,
1982 #[serde(default)]
1983 title: String,
1984 #[serde(default)]
1985 status_check_rollup: Vec<GhCheck>,
1986 #[serde(default)]
1993 merge_state_status: String,
1994 #[serde(default)]
1995 reviews: Vec<GhReview>,
1996 #[serde(default)]
1997 comments: Vec<GhComment>,
1998}
1999
2000#[derive(Debug, Deserialize)]
2005#[serde(rename_all = "camelCase")]
2006struct GhCheck {
2007 #[serde(default)]
2008 name: Option<String>,
2009 #[serde(default)]
2010 context: Option<String>,
2011 #[serde(default)]
2012 status: Option<String>,
2013 #[serde(default)]
2014 conclusion: Option<String>,
2015 #[serde(default)]
2016 state: Option<String>,
2017 #[serde(default)]
2018 details_url: Option<String>,
2019 #[serde(default)]
2020 target_url: Option<String>,
2021}
2022
2023impl GhCheck {
2024 fn label(&self) -> String {
2026 self.name
2027 .clone()
2028 .or_else(|| self.context.clone())
2029 .unwrap_or_else(|| "(unnamed check)".to_owned())
2030 }
2031
2032 fn url(&self) -> Option<&str> {
2034 self.details_url
2035 .as_deref()
2036 .or(self.target_url.as_deref())
2037 .filter(|u| !u.is_empty())
2038 }
2039
2040 fn verdict(&self) -> Verdict {
2048 if let Some(status) = self.status.as_deref() {
2049 if !status.eq_ignore_ascii_case("COMPLETED") {
2050 return Verdict::Pending;
2051 }
2052 }
2053 let outcome = self
2054 .conclusion
2055 .as_deref()
2056 .or(self.state.as_deref())
2057 .unwrap_or("");
2058 match outcome.to_ascii_uppercase().as_str() {
2059 "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
2060 "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
2061 | "ACTION_REQUIRED" => Verdict::Fail,
2062 "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
2063 Verdict::Pending
2064 }
2065 _ => Verdict::Unknown,
2066 }
2067 }
2068}
2069
2070#[derive(Debug, Deserialize)]
2071struct GhAuthor {
2072 #[serde(default)]
2073 login: String,
2074}
2075
2076#[derive(Debug, Deserialize)]
2077struct GhReview {
2078 #[serde(default)]
2079 author: GhAuthor,
2080 #[serde(default)]
2081 body: String,
2082}
2083
2084#[derive(Debug, Deserialize)]
2085struct GhComment {
2086 #[serde(default)]
2087 author: GhAuthor,
2088 #[serde(default)]
2089 body: String,
2090}
2091
2092#[derive(Debug, Deserialize)]
2093struct GhUser {
2094 #[serde(default)]
2095 login: String,
2096}
2097
2098#[derive(Debug, Deserialize)]
2099struct GhInline {
2100 #[serde(default)]
2101 user: GhUser,
2102 #[serde(default)]
2103 path: Option<String>,
2104 #[serde(default)]
2105 line: Option<u64>,
2106 #[serde(default)]
2107 body: String,
2108}
2109
2110impl Default for GhAuthor {
2111 fn default() -> Self {
2112 Self {
2113 login: "(unknown)".to_owned(),
2114 }
2115 }
2116}
2117
2118impl Default for GhUser {
2119 fn default() -> Self {
2120 Self {
2121 login: "(unknown)".to_owned(),
2122 }
2123 }
2124}
2125
2126#[cfg(test)]
2127mod tests {
2128 use super::*;
2129
2130 const GREEN_OPEN: &str = r####"{
2132 "url": "https://github.com/yukimemi/magi/pull/10",
2133 "number": 10,
2134 "state": "OPEN",
2135 "mergeStateStatus": "CLEAN",
2136 "statusCheckRollup": [
2137 {
2138 "__typename": "CheckRun",
2139 "conclusion": "SKIPPED",
2140 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2141 "name": "review",
2142 "status": "COMPLETED",
2143 "workflowName": "claude-review"
2144 },
2145 {
2146 "__typename": "CheckRun",
2147 "conclusion": "SUCCESS",
2148 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2149 "name": "check (ubuntu-latest)",
2150 "status": "COMPLETED",
2151 "workflowName": "CI"
2152 },
2153 {
2154 "__typename": "CheckRun",
2155 "conclusion": "SUCCESS",
2156 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2157 "name": "rustfmt",
2158 "status": "COMPLETED",
2159 "workflowName": "CI"
2160 },
2161 {
2162 "__typename": "StatusContext",
2163 "context": "CodeRabbit",
2164 "state": "SUCCESS",
2165 "targetUrl": ""
2166 }
2167 ],
2168 "reviews": [],
2169 "comments": [
2170 {
2171 "author": {
2172 "login": "coderabbitai"
2173 },
2174 "authorAssociation": "NONE",
2175 "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"
2176 }
2177 ]
2178}"####;
2179
2180 const RED_OPEN: &str = r####"{
2182 "url": "https://github.com/yukimemi/magi/pull/9",
2183 "number": 9,
2184 "state": "OPEN",
2185 "mergeStateStatus": "UNSTABLE",
2186 "statusCheckRollup": [
2187 {
2188 "__typename": "CheckRun",
2189 "conclusion": "SUCCESS",
2190 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2191 "name": "check (ubuntu-latest)",
2192 "status": "COMPLETED",
2193 "workflowName": "CI"
2194 },
2195 {
2196 "__typename": "CheckRun",
2197 "conclusion": "SUCCESS",
2198 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2199 "name": "rustfmt",
2200 "status": "COMPLETED",
2201 "workflowName": "CI"
2202 },
2203 {
2204 "__typename": "CheckRun",
2205 "conclusion": "FAILURE",
2206 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2207 "name": "editorconfig",
2208 "status": "COMPLETED",
2209 "workflowName": "CI"
2210 },
2211 {
2212 "__typename": "StatusContext",
2213 "context": "CodeRabbit",
2214 "state": "SUCCESS",
2215 "targetUrl": ""
2216 }
2217 ],
2218 "reviews": [],
2219 "comments": [
2220 {
2221 "author": {
2222 "login": "coderabbitai"
2223 },
2224 "authorAssociation": "NONE",
2225 "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"
2226 }
2227 ]
2228}"####;
2229
2230 const PENDING_OPEN: &str = r####"{
2232 "url": "https://github.com/yukimemi/magi/pull/9",
2233 "number": 9,
2234 "state": "OPEN",
2235 "statusCheckRollup": [
2236 {
2237 "__typename": "CheckRun",
2238 "conclusion": "SUCCESS",
2239 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2240 "name": "check (ubuntu-latest)",
2241 "status": "COMPLETED",
2242 "workflowName": "CI"
2243 },
2244 {
2245 "__typename": "CheckRun",
2246 "conclusion": "SUCCESS",
2247 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2248 "name": "rustfmt",
2249 "status": "COMPLETED",
2250 "workflowName": "CI"
2251 },
2252 {
2253 "__typename": "CheckRun",
2254 "conclusion": null,
2255 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2256 "name": "editorconfig",
2257 "status": "IN_PROGRESS",
2258 "workflowName": "CI"
2259 },
2260 {
2261 "__typename": "StatusContext",
2262 "context": "CodeRabbit",
2263 "state": "SUCCESS",
2264 "targetUrl": ""
2265 }
2266 ],
2267 "reviews": [],
2268 "comments": []
2269}"####;
2270
2271 const MERGED: &str = r####"{
2273 "url": "https://github.com/yukimemi/magi/pull/16",
2274 "number": 16,
2275 "state": "MERGED",
2276 "statusCheckRollup": [
2277 {
2278 "__typename": "CheckRun",
2279 "conclusion": "SUCCESS",
2280 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2281 "name": "check (ubuntu-latest)",
2282 "status": "COMPLETED",
2283 "workflowName": "CI"
2284 },
2285 {
2286 "__typename": "CheckRun",
2287 "conclusion": "SUCCESS",
2288 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2289 "name": "review",
2290 "status": "COMPLETED",
2291 "workflowName": "claude-review"
2292 }
2293 ],
2294 "reviews": [],
2295 "comments": []
2296}"####;
2297
2298 const REVIEWED_OPEN: &str = r####"{
2300 "url": "https://github.com/yukimemi/magi/pull/12",
2301 "number": 12,
2302 "state": "OPEN",
2303 "statusCheckRollup": [
2304 {
2305 "__typename": "CheckRun",
2306 "conclusion": "SUCCESS",
2307 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2308 "name": "check (ubuntu-latest)",
2309 "status": "COMPLETED",
2310 "workflowName": "CI"
2311 },
2312 {
2313 "__typename": "CheckRun",
2314 "conclusion": "SUCCESS",
2315 "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2316 "name": "review",
2317 "status": "COMPLETED",
2318 "workflowName": "claude-review"
2319 }
2320 ],
2321 "reviews": [
2322 {
2323 "author": {
2324 "login": "claude"
2325 },
2326 "state": "COMMENTED",
2327 "body": ""
2328 }
2329 ],
2330 "comments": [
2331 {
2332 "author": {
2333 "login": "coderabbitai"
2334 },
2335 "authorAssociation": "NONE",
2336 "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"
2337 },
2338 {
2339 "author": {
2340 "login": "claude"
2341 },
2342 "authorAssociation": "NONE",
2343 "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"
2344 }
2345 ]
2346}"####;
2347
2348 const INLINE: &str = r####"[
2350 {
2351 "user": {
2352 "login": "claude[bot]"
2353 },
2354 "path": "src/graph.rs",
2355 "line": 231,
2356 "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"
2357 }
2358]"####;
2359
2360 const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
2362<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
2363
2364> [!IMPORTANT]
2365> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
2366>
2367> This repository does not receive automatic reviews because it has fewer than 10 stars.
2368>
2369> <details>
2370> <summary>⚙️ Run configuration</summary>
2371>
2372> **Configuration used**: defaults
2373>
2374> **Review profile**: CHILL
2375>
2376> **Plan**: Team
2377>
2378> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
2379>
2380> </details>
2381
2382<!-- end of auto-generated comment: skip review by coderabbit.ai -->
2383
2384<!-- tips_start -->
2385
2386---
2387
2388Thanks 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.
2389
2390<details>
2391<summary>❤️ Share</summary>
2392
2393- [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"####;
2394
2395 const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
2397
2398---
2399### Reviewing PR #16
2400
2401- [x] Read AGENTS.md conventions
2402- [x] Review `src/daemon.rs` changes
2403- [x] Review `src/main.rs` changes (new `doctor` reporting)
2404- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
2405- [x] Check test coverage for new behavior
2406- [x] Run verification commands (blocked — see note)
2407- [x] Post findings"####;
2408
2409 const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
2411
2412---
2413### Review: `magi review <branch>` — cheap-half-only graph
2414
2415Read 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.
2416
2417**Correctness**
2418
2419- 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"####;
2420
2421 fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
2422 PrState {
2423 url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
2424 number: 16,
2425 state: PrLifecycle::Open,
2426 checks,
2427 blocking: if matches!(checks, Checks::Red) {
2431 Blocking::Yes
2432 } else {
2433 Blocking::No
2434 },
2435 failing: failing.iter().map(|s| (*s).to_owned()).collect(),
2436 review_comments: (0..comments)
2437 .map(|i| ReviewComment {
2438 author: "coderabbitai".to_owned(),
2439 path: Some("src/graph.rs".to_owned()),
2440 line: Some(231),
2441 body: format!("finding {i}"),
2442 })
2443 .collect(),
2444 }
2445 }
2446
2447 #[test]
2448 fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
2449 let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
2450 assert_eq!(state.number, 10);
2451 assert_eq!(state.state, PrLifecycle::Open);
2452 assert_eq!(state.checks, Checks::Green);
2453 assert!(state.failing.is_empty());
2454 assert!(
2455 state.review_comments.is_empty(),
2456 "the only comment is CodeRabbit's trigger notice: {:?}",
2457 state.review_comments
2458 );
2459 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
2460 }
2461
2462 #[test]
2463 fn a_failing_check_parses_as_red_and_is_named() {
2464 let state = parse_pr(RED_OPEN).expect("red fixture parses");
2465 assert_eq!(state.checks, Checks::Red);
2466 assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
2467 let mut blocking = state.clone();
2474 blocking.blocking = Blocking::Yes;
2475 match decide(&blocking, 0, 4, Duration::ZERO) {
2476 Step::Fix { reason } => {
2477 assert!(reason.contains("editorconfig"), "reason: {reason}");
2478 assert!(reason.contains("failing"), "reason: {reason}");
2479 }
2480 other => panic!("expected a fix round, got {other:?}"),
2481 }
2482 }
2483
2484 #[test]
2485 fn a_check_still_running_parses_as_pending_and_is_waited_for() {
2486 let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
2487 assert_eq!(state.checks, Checks::Pending);
2488 assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
2489 }
2490
2491 #[test]
2492 fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
2493 let state = parse_pr(MERGED).expect("merged fixture parses");
2494 assert_eq!(state.state, PrLifecycle::Merged);
2495 assert_eq!(
2496 decide(&state, 0, 4, Duration::ZERO),
2497 Step::Done { merged: true }
2498 );
2499 }
2500
2501 #[test]
2502 fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
2503 let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
2504 assert_eq!(state.checks, Checks::Green);
2505 let authors: Vec<&str> = state
2506 .review_comments
2507 .iter()
2508 .map(|c| c.author.as_str())
2509 .collect();
2510 assert_eq!(
2511 authors,
2512 vec!["claude"],
2513 "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
2514 );
2515 match decide(&state, 0, 4, Duration::ZERO) {
2516 Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
2517 other => panic!("expected a fix round, got {other:?}"),
2518 }
2519 }
2520
2521 #[test]
2522 fn inline_review_comments_keep_their_file_and_line() {
2523 let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
2524 assert_eq!(comments.len(), 1);
2525 assert_eq!(comments[0].author, "claude[bot]");
2526 assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
2527 assert_eq!(comments[0].line, Some(231));
2528 assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
2529 }
2530
2531 #[test]
2532 fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
2533 assert!(
2534 is_noise(CODERABBIT_TRIGGER),
2535 "CodeRabbit's trigger notice declares itself not a review"
2536 );
2537 assert!(
2538 is_noise(CLAUDE_CHECKLIST),
2539 "a progress checklist asks for nothing"
2540 );
2541 assert!(
2542 !is_noise(CLAUDE_FINDING),
2543 "a review that names a bug is input, not noise"
2544 );
2545
2546 let mut clean = pr(Checks::Green, &[], 0);
2547 clean.review_comments.push(ReviewComment {
2548 author: "coderabbitai".to_owned(),
2549 path: None,
2550 line: None,
2551 body: CODERABBIT_TRIGGER.to_owned(),
2552 });
2553 clean.review_comments.retain(|c| !is_noise(&c.body));
2554 assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
2555
2556 let mut found = pr(Checks::Green, &[], 0);
2557 found.review_comments.push(ReviewComment {
2558 author: "claude".to_owned(),
2559 path: None,
2560 line: None,
2561 body: CLAUDE_FINDING.to_owned(),
2562 });
2563 found.review_comments.retain(|c| !is_noise(&c.body));
2564 assert!(matches!(
2565 decide(&found, 0, 4, Duration::ZERO),
2566 Step::Fix { .. }
2567 ));
2568 }
2569
2570 #[test]
2571 fn the_policy_table_holds_for_every_combination_that_matters() {
2572 let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
2573 (
2574 "pending checks are waited for, even on the last round",
2575 pr(Checks::Pending, &[], 0),
2576 4,
2577 4,
2578 Duration::ZERO,
2579 Step::Wait,
2580 ),
2581 (
2582 "red checks are fixed",
2583 pr(Checks::Red, &["editorconfig"], 0),
2584 0,
2585 4,
2586 Duration::ZERO,
2587 Step::Fix {
2588 reason: "1 check(s) failing: editorconfig".to_owned(),
2589 },
2590 ),
2591 (
2592 "green with comments is fixed, not merged",
2593 pr(Checks::Green, &[], 2),
2594 1,
2595 4,
2596 Duration::ZERO,
2597 Step::Fix {
2598 reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
2599 .to_owned(),
2600 },
2601 ),
2602 (
2603 "green and clean merges",
2604 pr(Checks::Green, &[], 0),
2605 3,
2606 4,
2607 Duration::ZERO,
2608 Step::Merge,
2609 ),
2610 (
2611 "an unreadable rollup is waited on while the grace lasts",
2612 pr(Checks::Unknown, &[], 0),
2613 0,
2614 4,
2615 Duration::ZERO,
2616 Step::Wait,
2617 ),
2618 (
2619 "an unreadable rollup is never merged once the grace is spent",
2620 pr(Checks::Unknown, &[], 0),
2621 0,
2622 4,
2623 CHECKS_GRACE,
2624 Step::GiveUp {
2625 reason: "no check status is readable on the pull request after 3 minute(s); \
2626 refusing to merge on a guess"
2627 .to_owned(),
2628 },
2629 ),
2630 ];
2631 for (what, state, round, budget, waited, want) in cases {
2632 assert_eq!(decide(&state, round, budget, waited), want, "{what}");
2633 }
2634 }
2635
2636 #[test]
2637 fn the_forge_verdict_survives_the_round_trip_from_gh() {
2638 let green = parse_pr(GREEN_OPEN).expect("parse");
2642 assert_eq!(green.blocking, Blocking::No);
2643 let red = parse_pr(RED_OPEN).expect("parse");
2644 assert_eq!(
2645 red.blocking,
2646 Blocking::No,
2647 "`UNSTABLE` is mergeable: the red check is one nobody requires"
2648 );
2649 assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
2650 let quiet =
2652 parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
2653 assert_eq!(quiet.blocking, Blocking::Unsaid);
2654 }
2655
2656 #[test]
2657 fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
2658 let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
2664 nonblocking.blocking = Blocking::No;
2665 assert_eq!(
2666 decide(&nonblocking, 0, 4, Duration::ZERO),
2667 Step::Merge,
2668 "the forge says nothing is in the way, so nothing is"
2669 );
2670
2671 let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
2673 blocking.blocking = Blocking::Yes;
2674 assert!(matches!(
2675 decide(&blocking, 0, 4, Duration::ZERO),
2676 Step::Fix { .. }
2677 ));
2678
2679 let mut commented = pr(Checks::Red, &["coverage"], 1);
2682 commented.blocking = Blocking::No;
2683 assert!(matches!(
2684 decide(&commented, 0, 4, Duration::ZERO),
2685 Step::Fix { .. }
2686 ));
2687
2688 let mut unsaid = pr(Checks::Red, &["coverage"], 0);
2690 unsaid.blocking = Blocking::Unsaid;
2691 assert!(matches!(
2692 decide(&unsaid, 0, 4, Duration::ZERO),
2693 Step::Fix { .. }
2694 ));
2695 }
2696
2697 #[test]
2698 fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
2699 let mut conflicted = pr(Checks::Green, &[], 0);
2704 conflicted.blocking = Blocking::Conflict;
2705 assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
2706
2707 let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
2711 red.blocking = Blocking::Conflict;
2712 assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
2713
2714 let mut merged = pr(Checks::Red, &[], 0);
2716 merged.blocking = Blocking::Conflict;
2717 merged.state = PrLifecycle::Merged;
2718 assert_eq!(
2719 decide(&merged, 0, 4, Duration::ZERO),
2720 Step::Done { merged: true }
2721 );
2722 }
2723
2724 #[test]
2725 fn the_forge_verdict_is_read_off_merge_state_status() {
2726 for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
2729 assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
2730 assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
2731 }
2732 assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
2733 assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
2734 assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
2735 for quiet in ["", "UNKNOWN"] {
2738 assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
2739 assert!(Blocking::of(quiet).stops_a_merge());
2740 }
2741 }
2742
2743 #[test]
2744 fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
2745 let argv = merge_argv(28, "Merge magi run ec12 (candidate B)");
2746 let jj = "could not determine current branch: failed to run git: not on any branch";
2748
2749 let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
2750 .expect("the forge says merged, so it merged");
2751 assert!(landed.ok);
2752 assert!(
2753 landed.detail.contains("but the pull request is merged"),
2754 "the record must not read as a clean success: {}",
2755 landed.detail
2756 );
2757 assert!(
2758 landed.detail.contains("not on any branch"),
2759 "and it must keep what the command actually said: {}",
2760 landed.detail
2761 );
2762
2763 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
2765 assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
2766 assert!(merged_after_all(&argv, jj, None).is_none());
2768 }
2769
2770 #[test]
2771 fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
2772 let mut state = pr(Checks::Red, &["editorconfig"], 3);
2773 state.state = PrLifecycle::Closed;
2774 assert_eq!(
2775 decide(&state, 0, 4, Duration::ZERO),
2776 Step::Done { merged: false },
2777 "a human closing the pull request ends the loop, whatever CI says"
2778 );
2779 }
2780
2781 #[test]
2782 fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
2783 let red = decide(
2784 &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
2785 4,
2786 4,
2787 Duration::ZERO,
2788 );
2789 match red {
2790 Step::GiveUp { reason } => {
2791 assert!(reason.contains("editorconfig"), "reason: {reason}");
2792 assert!(reason.contains("test (macos)"), "reason: {reason}");
2793 assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
2794 }
2795 other => panic!("expected a give-up, got {other:?}"),
2796 }
2797
2798 let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
2799 match commented {
2800 Step::GiveUp { reason } => {
2801 assert!(reason.contains("unresolved"), "reason: {reason}");
2802 assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
2803 }
2804 other => panic!("expected a give-up, got {other:?}"),
2805 }
2806 }
2807
2808 #[test]
2809 fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
2810 let candidate_commit = "magi: candidate A (uncommitted work)";
2811 let subject = merge_subject(candidate_commit, "add retries to the uploader");
2812 let argv = merge_argv(16, &subject);
2813
2814 assert!(argv.contains(&"--squash".to_owned()));
2815 assert!(argv.contains(&"--delete-branch".to_owned()));
2816 assert!(argv.contains(&"--subject".to_owned()));
2817 assert_eq!(
2818 argv.last().map(String::as_str),
2819 Some("add retries to the uploader"),
2820 "the subject must not be the candidate commit message"
2821 );
2822 assert_ne!(subject, candidate_commit);
2823 }
2824
2825 #[test]
2826 fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
2827 assert_eq!(
2828 merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
2829 "feat: a queue, an unattended loop, and a phone UI"
2830 );
2831 assert_eq!(
2832 merge_subject("", "# port the retry logic\n\ndetails"),
2833 "port the retry logic",
2834 "an empty title falls back to the task's first line, heading marks stripped"
2835 );
2836 }
2837
2838 #[test]
2839 fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
2840 let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
2841 assert_eq!(job_of(url).as_deref(), Some("100114323572"));
2842 assert_eq!(run_of(url).as_deref(), Some("33587406996"));
2843 assert_eq!(job_of("https://coderabbit.ai/status"), None);
2844 assert_eq!(run_of(""), None);
2845 }
2846
2847 #[test]
2848 fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
2849 let mut out = Vec::new();
2850 push_if_outstanding(
2851 &mut out,
2852 ReviewComment {
2853 author: "yukimemi".to_owned(),
2854 path: None,
2855 line: None,
2856 body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
2857 },
2858 );
2859 assert!(out.is_empty());
2860 }
2861
2862 fn run_state() -> RunState {
2866 RunState::new(
2867 std::path::PathBuf::from("/repo/magi"),
2868 "main".to_owned(),
2869 "abcdef1234".to_owned(),
2870 "add retries to the uploader".to_owned(),
2871 crate::config::Config::default(),
2872 )
2873 }
2874
2875 fn green_pr() -> PrState {
2876 PrState {
2877 url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
2878 number: 42,
2879 state: PrLifecycle::Open,
2880 checks: Checks::Green,
2881 blocking: Blocking::No,
2883 failing: Vec::new(),
2884 review_comments: vec![ReviewComment {
2885 author: "coderabbitai".to_owned(),
2886 path: Some("src/land.rs".to_owned()),
2887 line: Some(212),
2888 body: "this branch never checks the exit code".to_owned(),
2889 }],
2890 }
2891 }
2892
2893 const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
2894
2895 fn panel() -> String {
2896 approval_panel(
2897 &run_state(),
2898 &green_pr(),
2899 NUMSTAT,
2900 "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
2901 &[
2902 "land: ask before merging".to_owned(),
2903 "land: colour the diff".to_owned(),
2904 ],
2905 "feat: merge approval from the phone",
2906 )
2907 }
2908
2909 #[test]
2910 fn the_approval_panel_carries_the_whole_case_for_the_merge() {
2911 let html = panel();
2912 for needle in [
2913 "42",
2914 "main",
2915 "src/land.rs",
2916 "src/web.rs",
2917 "assets/logo.png",
2918 "feat: merge approval from the phone",
2919 "land: ask before merging",
2920 "land: colour the diff",
2921 "coderabbitai",
2922 "this branch never checks the exit code",
2923 "green",
2924 ] {
2925 assert!(html.contains(needle), "the panel must state `{needle}`");
2926 }
2927 }
2928
2929 #[test]
2930 fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
2931 let html = panel();
2932 assert!(!html.contains("<script"), "no script survives the csp");
2933 assert!(!html.contains("<form"), "form-action is 'none'");
2934 let pr = green_pr();
2935 assert_eq!(
2936 html.matches("http").count(),
2937 html.matches(pr.url.as_str()).count(),
2938 "the only http url in the panel is the pull request's own link"
2939 );
2940 }
2941
2942 #[test]
2943 fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
2944 let html = panel();
2945 assert!(
2946 html.contains(">+</span>"),
2947 "an added line carries a `+` in the gutter, not only a background"
2948 );
2949 assert!(
2950 html.contains(">-</span>"),
2951 "a removed line carries a `-` in the gutter, not only a background"
2952 );
2953 assert!(
2954 html.contains(">new line</span>"),
2955 "the marker is moved to the gutter, so the body is printed once without it"
2956 );
2957 }
2958
2959 #[test]
2960 fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
2961 let total = DIFF_MAX_LINES + 100;
2962 let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
2963 let html = approval_panel(
2964 &run_state(),
2965 &green_pr(),
2966 NUMSTAT,
2967 &diff,
2968 &[],
2969 "feat: something long",
2970 );
2971 assert!(
2972 html.contains(&format!("100 of {total} diff lines omitted")),
2973 "the note must say exactly how much was cut"
2974 );
2975 assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
2976 assert!(
2977 !html.contains(&format!("line {DIFF_MAX_LINES}")),
2978 "nothing past the threshold is rendered"
2979 );
2980 assert!(
2981 html.contains("/repo/magi"),
2982 "the note says where the rest is"
2983 );
2984 }
2985
2986 #[test]
2987 fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
2988 let html = approval_panel(
2989 &run_state(),
2990 &green_pr(),
2991 "1\t2\tsrc/<b>&\"x\"'.rs",
2992 "",
2993 &[],
2994 "subject",
2995 );
2996 assert!(html.contains("src/<b>&"x"'.rs"));
2997 assert!(
2998 !html.contains("<b>"),
2999 "an agent-influenced path must never become markup"
3000 );
3001 }
3002
3003 #[tokio::test]
3004 async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
3005 let a = std::path::PathBuf::from("/repo/a");
3006 let b = std::path::PathBuf::from("/repo/b");
3007
3008 let held = repo_merge_lock(&a).lock_owned().await;
3009
3010 assert!(
3013 repo_merge_lock(&a).try_lock().is_err(),
3014 "a second merge into the same repository must not proceed concurrently"
3015 );
3016
3017 assert!(
3021 repo_merge_lock(&b).try_lock().is_ok(),
3022 "a different repository's merge lock must be independent"
3023 );
3024
3025 drop(held);
3026 assert!(
3027 repo_merge_lock(&a).try_lock().is_ok(),
3028 "the lock is released once the holder is done"
3029 );
3030 }
3031
3032 #[test]
3033 fn only_the_merge_choice_merges_and_silence_holds() {
3034 let table = [
3035 (None, Approval::Hold),
3036 (Some("merge"), Approval::Merge),
3037 (Some(" merge\n"), Approval::Merge),
3038 (Some("hold"), Approval::Hold),
3039 (Some(""), Approval::Hold),
3040 (Some("yes"), Approval::Hold),
3041 ];
3042 for (answer, want) in table {
3043 assert_eq!(
3044 approval(answer),
3045 want,
3046 "answer {answer:?} must resolve to {want:?}"
3047 );
3048 }
3049 }
3050
3051 #[tokio::test]
3052 async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
3053 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3054 let mut state = run_state();
3055 state.config.graph.land_approval = true;
3056 let pr = green_pr();
3057
3058 let gate = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3059 assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
3060 assert!(
3061 !state.parked,
3062 "approval_gate itself never sets `parked`; only its caller does"
3063 );
3064
3065 let store = ask::Questions::open();
3066 let filed: Vec<_> = store
3067 .list()
3068 .into_iter()
3069 .filter(|q| q.run == state.id)
3070 .collect();
3071 assert_eq!(filed.len(), 1, "exactly one question is filed");
3072 assert_eq!(filed[0].node, APPROVAL_NODE);
3073 assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
3074 assert!(filed[0].status.open());
3075
3076 let again = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3080 assert_eq!(again, ApprovalGate::Pending);
3081 let still_one = store
3082 .list()
3083 .into_iter()
3084 .filter(|q| q.run == state.id)
3085 .count();
3086 assert_eq!(
3087 still_one, 1,
3088 "asking twice must not double-file the question"
3089 );
3090 }
3091
3092 #[tokio::test]
3093 async fn approving_the_existing_question_is_read_back_as_approved() {
3094 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3095 let mut state = run_state();
3096 state.config.graph.land_approval = true;
3097 let pr = green_pr();
3098 assert_eq!(
3099 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3100 ApprovalGate::Pending
3101 );
3102
3103 let store = ask::Questions::open();
3104 let mut q = store
3105 .list()
3106 .into_iter()
3107 .find(|q| q.run == state.id)
3108 .expect("filed above");
3109 q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
3110 store.put(&mut q).unwrap();
3111
3112 assert_eq!(
3113 approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3114 ApprovalGate::Approved
3115 );
3116 }
3117
3118 #[tokio::test]
3119 async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
3120 crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3121 let store = ask::Questions::open();
3122
3123 let mut held_state = run_state();
3124 held_state.config.graph.land_approval = true;
3125 let pr = green_pr();
3126 approval_gate(&mut held_state, &pr, "feat: x")
3127 .await
3128 .unwrap();
3129 let mut q = store
3130 .list()
3131 .into_iter()
3132 .find(|q| q.run == held_state.id)
3133 .expect("filed above");
3134 q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
3135 store.put(&mut q).unwrap();
3136 assert_eq!(
3137 approval_gate(&mut held_state, &pr, "feat: x")
3138 .await
3139 .unwrap(),
3140 ApprovalGate::Held
3141 );
3142
3143 let mut abandoned_state = run_state();
3144 abandoned_state.config.graph.land_approval = true;
3145 approval_gate(&mut abandoned_state, &pr, "feat: x")
3146 .await
3147 .unwrap();
3148 let mut q = store
3149 .list()
3150 .into_iter()
3151 .find(|q| q.run == abandoned_state.id)
3152 .expect("filed above");
3153 q.abandon("no answer within the timeout");
3154 store.put(&mut q).unwrap();
3155 assert_eq!(
3156 approval_gate(&mut abandoned_state, &pr, "feat: x")
3157 .await
3158 .unwrap(),
3159 ApprovalGate::Held,
3160 "silence must never merge"
3161 );
3162 }
3163
3164 #[test]
3165 fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
3166 let rows = parse_numstat(NUMSTAT);
3167 assert_eq!(
3168 rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
3169 ["src/web.rs", "src/land.rs", "assets/logo.png"]
3170 );
3171 assert_eq!(rows[2].added, None, "a binary file has no line counts");
3172 }
3173 #[test]
3174 fn the_approval_speaks_the_language_the_repository_is_configured_for() {
3175 let mut state = run_state();
3179 state.config.graph.language = "ja".to_owned();
3180 let pr = green_pr();
3181 let commits = ["c1".to_owned()];
3182
3183 let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3184 assert!(ja.contains("lang=\"ja\""), "the document must declare it");
3185 assert!(ja.contains("squash されるコミット"), "{ja}");
3186 assert!(ja.contains("レビューコメント"), "{ja}");
3187 assert!(ja.contains("差分"), "{ja}");
3188 assert!(
3189 !ja.contains("Commits being squashed"),
3190 "no English left over"
3191 );
3192
3193 let w = words("ja");
3194 assert!(w.approval_summary(17, "feat: x").contains("マージ"));
3195 assert!(
3196 w.approval_detail("http://x/1", "main", "feat: x")
3197 .contains("パネル")
3198 );
3199
3200 assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
3202 assert!(ja.contains("feat: x"), "nor is the merge subject");
3203
3204 state.config.graph.language = "en".to_owned();
3207 let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3208 assert!(en.contains("Commits being squashed"), "{en}");
3209 assert_eq!(words("Klingon").html_lang, "en");
3210 }
3211}