Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::BTreeSet;
42use std::fmt::Write as _;
43use std::path::Path;
44use std::time::Duration;
45
46use anyhow::{Context as _, Result, bail};
47use serde::Deserialize;
48
49use crate::agent::{self, Invocation, SeatState};
50use crate::ask;
51use crate::config::{AgentSpec, MergeMode};
52use crate::git;
53use crate::run::{MergeOutcome, RunState, RunStatus, tail};
54
55/// How often the pull request is re-read while its checks are still running.
56///
57/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
58/// entirely on `gh` invocations, and anything much longer adds latency to every
59/// single round of a loop that already waits for agents.
60pub const POLL: Duration = Duration::from_secs(30);
61
62/// How long one wait may last before landing gives up on the checks finishing.
63///
64/// A workflow that has not settled in forty-five minutes is stuck on a runner
65/// queue, a missing approval, or a hung job - none of which more polling fixes,
66/// and all of which a person needs to see.
67pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
68
69/// How long the checks may stay unreadable before landing gives up on them.
70///
71/// GitHub registers a workflow run some seconds after the branch is pushed, so
72/// immediately after a pull request is opened "no checks" and "no CI in this
73/// repository" look identical. Measured on run 01c2: magi opened pull request
74/// 22, read `unknown` four seconds later, refused to merge on a guess and
75/// marked the run blocked - and every check on that pull request was green
76/// minutes afterwards, with the whole competition then re-run from scratch for
77/// a task that was already finished. Three minutes is well past the observed
78/// registration delay and still bounded, so a repository that genuinely has no
79/// checks costs one three-minute wait and then says so.
80pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
81
82/// Bytes of failing log kept per check. The fixer needs the assertion and the
83/// frame around it, not the forty thousand lines of `cargo` output above it.
84const LOG_TAIL: usize = 4_000;
85
86/// Failing checks whose logs are fetched. Beyond a handful the failures share a
87/// cause, and fetching each one costs a `gh` round trip.
88const MAX_LOGS: usize = 3;
89
90/// Marker carried by every comment magi posts on a pull request.
91///
92/// Without it magi's own "still failing" comment is indistinguishable from a
93/// reviewer's, and the next observation would hand magi's own prose to the
94/// fixer as a finding.
95pub const MARKER: &str = "<!-- magi:land -->";
96
97/// Markers a bot puts in a comment to say that the comment is not a review.
98///
99/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
100/// the walkthrough summary, the "thanks for using" footer - and its actual
101/// findings arrive as *inline* review comments with a path and a line. Taking
102/// the bot at its word is more honest than guessing from prose, and it is the
103/// difference between a fix round that has something to fix and one that asks
104/// an agent to act on a quota notice.
105const NOT_A_REVIEW: [&str; 3] = [
106    "skip review by coderabbit.ai",
107    "summarize by coderabbit.ai",
108    "<!-- tips_start -->",
109];
110
111/// Where a pull request is in its life.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum PrLifecycle {
114    /// Still ours to land.
115    Open,
116    /// Already merged, by us or by a person.
117    Merged,
118    /// Closed without merging.
119    Closed,
120}
121
122/// The aggregate verdict of a pull request's checks.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum Checks {
125    /// At least one check has not finished.
126    Pending,
127    /// Every check passed (a skipped check counts as passed: the review
128    /// workflow skips release and bot pull requests by design).
129    Green,
130    /// At least one check finished without passing.
131    Red,
132    /// Nothing readable - no rollup at all, or a status magi does not know.
133    Unknown,
134}
135
136impl PrLifecycle {
137    /// Stable lower-case name, as the API and the reports spell it.
138    pub fn as_str(self) -> &'static str {
139        match self {
140            Self::Open => "open",
141            Self::Merged => "merged",
142            Self::Closed => "closed",
143        }
144    }
145}
146
147impl Checks {
148    /// Stable lower-case name, as the API and the reports spell it.
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::Pending => "pending",
152            Self::Green => "green",
153            Self::Red => "red",
154            Self::Unknown => "unknown",
155        }
156    }
157}
158
159/// One outstanding review comment, human or bot.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct ReviewComment {
162    /// Login of whoever wrote it.
163    pub author: String,
164    /// File it was left on, for inline review comments.
165    pub path: Option<String>,
166    /// Line it was left on, when the comment is inline and still anchored.
167    pub line: Option<u64>,
168    /// The comment itself, as written.
169    pub body: String,
170}
171
172/// One observation of a pull request.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PrState {
175    /// Pull request url, as `gh` reports it.
176    pub url: String,
177    /// Pull request number.
178    pub number: u64,
179    /// Open, merged, or closed.
180    pub state: PrLifecycle,
181    /// Aggregate check verdict.
182    pub checks: Checks,
183    /// Names of the checks that finished without passing.
184    pub failing: Vec<String>,
185    /// Comments that still want an answer, human and bot.
186    pub review_comments: Vec<ReviewComment>,
187    /// Whether the forge itself considers the failures blocking.
188    pub blocking: Blocking,
189}
190
191/// Whether a failing check actually stands between the pull request and
192/// `main`, according to the forge.
193///
194/// The rollup lists every check equally, so `coverage` going red on a
195/// repository that deliberately does not require it looked exactly like a
196/// broken build - and magi answered by spending a fix round on a change that
197/// was fine. Pull request 37 had to be merged by hand for that reason: the
198/// only red check was `editorconfig`, which was failing because the *action*
199/// could not fetch its own binary, and which the repository does not require.
200///
201/// `mergeStateStatus` is where GitHub applies the required-check set, so it
202/// is the one field that can tell the difference.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum Blocking {
205    /// Required checks are satisfied and the branch merges cleanly.
206    No,
207    /// Something required is failing or missing.
208    Yes,
209    /// The branch no longer merges: the base moved under it.
210    Conflict,
211    /// The forge did not say - an older `gh`, or a token without the scope.
212    /// Treated as `Yes`, because refusing to guess is the rule everywhere
213    /// else in this module.
214    Unsaid,
215}
216
217impl Blocking {
218    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
219    fn of(raw: &str) -> Self {
220        match raw.to_ascii_uppercase().as_str() {
221            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
222            // non-required check failing or still running.
223            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
224            "DIRTY" => Self::Conflict,
225            "" | "UNKNOWN" => Self::Unsaid,
226            // BLOCKED, BEHIND, DRAFT: something has to change first.
227            _ => Self::Yes,
228        }
229    }
230
231    /// Does this stand between the pull request and the base branch?
232    #[must_use]
233    pub fn stops_a_merge(self) -> bool {
234        !matches!(self, Self::No)
235    }
236}
237
238/// What the loop decided to do next. Pure, so the policy is testable.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum Step {
241    /// Checks are still running; re-read the pull request after [`POLL`].
242    Wait,
243    /// The base moved and the branch no longer merges: rebase it.
244    ///
245    /// Not a fix round. Nothing is wrong with the change - a competition
246    /// that runs for two hours against a repository merging pull requests
247    /// all day conflicts on the way in, and that is arithmetic rather than a
248    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
249    /// this.
250    Rebase,
251    /// Red checks or unresolved comments; run a fix round.
252    Fix {
253        /// What is unhappy, in one line, for the run log and the fix prompt.
254        reason: String,
255    },
256    /// Green and nothing outstanding; merge it.
257    Merge,
258    /// The pull request left our hands.
259    Done {
260        /// Did it land, or was it closed?
261        merged: bool,
262    },
263    /// Stop and leave the pull request to a person.
264    GiveUp {
265        /// Why magi stopped, in one line.
266        reason: String,
267    },
268}
269
270/// The outcome to record when `gh pr merge` exits non-zero, given what the
271/// pull request looked like immediately afterwards.
272///
273/// `gh pr merge` merges server-side first and only then does local work -
274/// deleting the branch, switching back to a base branch - so a non-zero exit
275/// does not mean the merge did not happen. In a jj-colocated repository it
276/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
277/// ends with "could not determine current branch: not on any branch" *after*
278/// the merge has landed. Run ec12 merged pull request 28 into `main` and
279/// recorded `ok: false`, and its task was held waiting for a merge that was
280/// already done.
281///
282/// So the forge is asked, and its answer wins - the same authority [`decide`]
283/// gives the pull request's own state over everything else. The recorded
284/// detail carries both facts, because "the command failed and the merge
285/// happened anyway" is exactly what someone reading the run later needs to
286/// know.
287///
288/// `None` means the merge really did not happen, including when the pull
289/// request could not be read at all: an unreadable answer is not evidence of
290/// success.
291fn merged_after_all(
292    argv: &[String],
293    stderr: &str,
294    after: Option<PrLifecycle>,
295) -> Option<MergeOutcome> {
296    if after? != PrLifecycle::Merged {
297        return None;
298    }
299    Some(MergeOutcome {
300        mode: MergeMode::Pr,
301        ok: true,
302        detail: format!(
303            "gh {} (the command reported `{}`, but the pull request is merged)",
304            argv.join(" "),
305            stderr.trim()
306        ),
307    })
308}
309
310/// Decide the next step. No I/O.
311///
312/// `round` counts the fix rounds already spent, so `round == budget` means the
313/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
314/// must not consume the allowance meant for actual fixes.
315///
316/// The order of the tests is the policy:
317///
318/// 1. **The pull request's own state wins.** A merge or a close that happened
319///    underneath us is the end of the story regardless of what the checks say.
320/// 2. **Pending beats red.** A check that is still running may yet fail, and one
321///    fix round that addresses every failure is cheaper than two that each
322///    address half - the fix pushes and restarts the whole suite anyway.
323/// 3. **Comments outrank green.** An unresolved comment holds the merge even
324///    when CI is happy; that is what a review is for.
325/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
326///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
327///    not been given its workflow runs yet. Past the grace they are treated as
328///    genuinely missing and magi stops rather than merge on a guess.
329pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
330    match pr.state {
331        PrLifecycle::Merged => return Step::Done { merged: true },
332        PrLifecycle::Closed => return Step::Done { merged: false },
333        PrLifecycle::Open => {}
334    }
335
336    // Before the checks: every check on a branch that cannot land is an
337    // answer about a state that cannot land.
338    if pr.blocking == Blocking::Conflict {
339        return Step::Rebase;
340    }
341
342    let spent = round >= budget;
343    match pr.checks {
344        Checks::Pending => Step::Wait,
345        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
346        Checks::Unknown => Step::GiveUp {
347            reason: format!(
348                "no check status is readable on the pull request after {} minute(s); \
349                 refusing to merge on a guess",
350                CHECKS_GRACE.as_secs() / 60
351            ),
352        },
353        // Red, but the forge says it does not stand in the way: the failing
354        // checks are ones this repository chose not to require. Spending a fix
355        // round on them asks an agent to repair something nobody is gating on
356        // - and pull request 37's only red check was an *action* that could
357        // not fetch its own binary. Merge, and name them so the record is
358        // honest about what was red when it landed.
359        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
360        Checks::Red => {
361            let what = format!(
362                "{} check(s) failing: {}",
363                pr.failing.len(),
364                pr.failing.join(", ")
365            );
366            if spent {
367                Step::GiveUp {
368                    reason: format!("{what} — still red after {budget} fix round(s)"),
369                }
370            } else {
371                Step::Fix { reason: what }
372            }
373        }
374        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
375        Checks::Green => {
376            let what = format!(
377                "checks are green but {} review comment(s) are unresolved: {}",
378                pr.review_comments.len(),
379                authors(&pr.review_comments)
380            );
381            if spent {
382                Step::GiveUp {
383                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
384                }
385            } else {
386                Step::Fix { reason: what }
387            }
388        }
389    }
390}
391
392/// Distinct comment authors, in the order they first appear.
393fn authors(comments: &[ReviewComment]) -> String {
394    let mut seen: Vec<&str> = Vec::new();
395    for c in comments {
396        if !seen.contains(&c.author.as_str()) {
397            seen.push(&c.author);
398        }
399    }
400    seen.join(", ")
401}
402
403/// The argv magi merges with, minus the program name.
404///
405/// `--subject` is the point of this function existing: see the module docs.
406pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
407    vec![
408        "pr".to_owned(),
409        "merge".to_owned(),
410        number.to_string(),
411        "--squash".to_owned(),
412        "--delete-branch".to_owned(),
413        "--subject".to_owned(),
414        subject.to_owned(),
415    ]
416}
417
418/// The squash subject to merge under.
419///
420/// The pull request title, unless it is empty or is a candidate branch's commit
421/// subject that leaked into the title - in which case the task's own first line
422/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
423/// reader nothing about what landed.
424pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
425    let title = pr_title.trim();
426    if !title.is_empty() && !title.starts_with("magi: candidate") {
427        return title.to_owned();
428    }
429    let first = instruction
430        .lines()
431        .map(str::trim)
432        .find(|l| !l.is_empty())
433        .unwrap_or("magi: land the winning candidate");
434    first.trim_start_matches(['#', ' ']).to_owned()
435}
436
437/// The choice that lets the merge happen, verbatim as the owner taps it.
438pub const APPROVE: &str = "merge";
439
440/// The choice that leaves the pull request open.
441pub const HOLD: &str = "hold";
442
443/// Graph node recorded on the approval question.
444///
445/// The phone keys its high-stakes card off this rather than off the choice
446/// strings, so renaming a button cannot silently downgrade the card that
447/// guards the one irreversible action magi takes.
448pub const APPROVAL_NODE: &str = "land-approval";
449
450/// Unified diff lines carried in the panel before it is truncated.
451///
452/// Four hundred: the panel is read on a 390px phone, where a diff line often
453/// wraps to two rows, so this is already a few thousand rows of scrolling -
454/// past that nobody is reading, and the bytes still count against the panel's
455/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
456/// cut and which worktree holds the whole patch.
457pub const DIFF_MAX_LINES: usize = 400;
458
459/// What the owner's answer to the approval question means.
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum Approval {
462    /// The owner said [`APPROVE`]. Merge.
463    Merge,
464    /// Anything else, including silence. Leave the pull request open.
465    Hold,
466}
467
468/// Read the owner's answer, where `None` is an unanswered question.
469///
470/// Silence is a hold. A timed-out question means the owner never saw it or
471/// never decided, and defaulting an irreversible merge to "yes" would make this
472/// gate worse than no gate at all: it would merge unattended while claiming to
473/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
474/// function does not recognise holds too.
475pub fn approval(answer: Option<&str>) -> Approval {
476    match answer {
477        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
478        _ => Approval::Hold,
479    }
480}
481
482/// Escape text for HTML, including both quote characters.
483///
484/// Every string in the panel is agent-influenced: a branch name, a file path, a
485/// commit subject, a review comment. The sandboxed frame stops such text from
486/// *running*, but it does not stop a `<` from ending the document early or a
487/// `"` from ending an attribute and inventing a new one - the panel would then
488/// render a lie, or not render at all. Both quotes are escaped because the same
489/// function is used inside attributes, where remembering which quote style the
490/// caller used is one mistake away from an injected attribute.
491fn esc(s: &str) -> String {
492    let mut out = String::with_capacity(s.len());
493    for c in s.chars() {
494        match c {
495            '&' => out.push_str("&amp;"),
496            '<' => out.push_str("&lt;"),
497            '>' => out.push_str("&gt;"),
498            '"' => out.push_str("&quot;"),
499            '\'' => out.push_str("&#39;"),
500            _ => out.push(c),
501        }
502    }
503    out
504}
505
506/// One row of the diffstat table.
507#[derive(Debug, Clone, PartialEq, Eq)]
508struct StatRow {
509    path: String,
510    /// `None` for a binary file, which `git` reports as `-`.
511    added: Option<u64>,
512    removed: Option<u64>,
513}
514
515impl StatRow {
516    /// Lines touched, for sorting. A binary file counts as zero rather than as
517    /// unknown, which puts it at the bottom where it needs no attention.
518    fn churn(&self) -> u64 {
519        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
520    }
521}
522
523/// Parse `git diff --numstat` into rows, biggest churn first.
524///
525/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
526/// terminal width, so counting its characters would print fabricated numbers in
527/// the one table an operator approves an irreversible action from.
528fn parse_numstat(numstat: &str) -> Vec<StatRow> {
529    let mut rows: Vec<StatRow> = numstat
530        .lines()
531        .filter_map(|line| {
532            let mut parts = line.splitn(3, '\t');
533            let added = parts.next()?.trim();
534            let removed = parts.next()?.trim();
535            let path = parts.next()?.trim();
536            if path.is_empty() {
537                return None;
538            }
539            Some(StatRow {
540                path: path.to_owned(),
541                added: added.parse().ok(),
542                removed: removed.parse().ok(),
543            })
544        })
545        .collect();
546    // Path breaks the tie so the same change always renders the same table; an
547    // operator comparing two panels should not see rows shuffle.
548    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
549    rows
550}
551
552/// How one diff line is shown: a gutter character, a style, and the body to
553/// print - which is the line minus its marker, so the marker appears exactly
554/// once, in the gutter.
555///
556/// The gutter is why this exists at all. The operator may be colour blind, or
557/// reading in sunlight with the screen dimmed, so an added line is never
558/// distinguished by its background alone: `+` and `-` sit in a fixed column,
559/// the same mark they already read in a terminal.
560fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
561    if line.starts_with("+++") || line.starts_with("---") {
562        (" ", "color:#57606a;font-weight:600", line)
563    } else if let Some(body) = line.strip_prefix('+') {
564        ("+", "background:#e6ffec;color:#0a3622", body)
565    } else if let Some(body) = line.strip_prefix('-') {
566        ("-", "background:#ffebe9;color:#5c1a17", body)
567    } else if line.starts_with("@@") {
568        ("~", "background:#eef2ff;color:#3730a3", line)
569    } else if let Some(body) = line.strip_prefix(' ') {
570        (" ", "", body)
571    } else {
572        (" ", "color:#57606a;font-weight:600", line)
573    }
574}
575
576/// The handful of words the approval panel says in its own voice.
577///
578/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
579/// the operator asked why the merge question spoke English on a repository
580/// configured for Japanese, and "because that string is a literal in Rust" is
581/// not an answer. Only the languages magi can actually check are translated;
582/// anything else falls back to English rather than shipping a guess, and that
583/// fallback is deliberate.
584struct Words {
585    html_lang: &'static str,
586    checks: &'static str,
587    nothing_failing: &'static str,
588    files_changed: &'static str,
589    commits: &'static str,
590    no_commits: &'static str,
591    comments: &'static str,
592    no_comments: &'static str,
593    diff: &'static str,
594    truncated: &'static str,
595    lands_as: &'static str,
596}
597
598const EN: Words = Words {
599    html_lang: "en",
600    checks: "Checks",
601    nothing_failing: "Nothing failing.",
602    files_changed: "file(s) changed",
603    commits: "Commits being squashed",
604    no_commits: "No commit subjects could be read from the branch.",
605    comments: "Review comments",
606    no_comments: "Nothing outstanding at this observation.",
607    diff: "Diff",
608    truncated: "Truncated",
609    lands_as: "They land as one commit titled",
610};
611
612const JA: Words = Words {
613    html_lang: "ja",
614    checks: "チェック",
615    nothing_failing: "失敗しているものはありません。",
616    files_changed: "ファイル変更",
617    commits: "squash されるコミット",
618    no_commits: "ブランチからコミット件名を読めませんでした。",
619    comments: "レビューコメント",
620    no_comments: "この時点で未対応のものはありません。",
621    diff: "差分",
622    truncated: "省略",
623    lands_as: "これらは次の件名の1コミットとして入ります:",
624};
625
626impl Words {
627    /// The clause after the merge subject. Split out because word order moves:
628    /// Japanese puts the subject before the verb, so a shared template with a
629    /// hole in the middle would read as machine translation.
630    fn lands_as_tail(&self) -> &'static str {
631        if self.html_lang == "ja" {
632            "。この件名も承認の対象です。"
633        } else {
634            ", which you are approving too."
635        }
636    }
637
638    /// The question's own one-line summary, which is what a phone shows first.
639    fn approval_summary(&self, number: u64, subject: &str) -> String {
640        if self.html_lang == "ja" {
641            format!("プルリクエスト #{number} をマージ: {subject}")
642        } else {
643            format!("merge pull request #{number}: {subject}")
644        }
645    }
646
647    /// The body under the summary, above the panel.
648    fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
649        if self.html_lang == "ja" {
650            format!(
651                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
652                 できる状態です。差分の要約・パッチ・squash されるコミットは\
653                 下のパネルにあります。"
654            )
655        } else {
656            format!(
657                "{url} is green and ready to squash into `{base}` as `{subject}`. \
658                 The panel holds the diffstat, the patch and the commits being squashed."
659            )
660        }
661    }
662
663    /// The truncation note, written whole in each language for the same reason.
664    fn truncated_note(
665        &self,
666        omitted: usize,
667        total: usize,
668        shown: usize,
669        where_: &str,
670        base: &str,
671        head: &str,
672    ) -> String {
673        if self.html_lang == "ja" {
674            format!(
675                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
676                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
677                 プルリクエストにあります。"
678            )
679        } else {
680            format!(
681                "{omitted} of {total} diff lines omitted after the first {shown}. \
682                 The whole patch is in <code>{where_}</code> \
683                 (<code>git diff {base}...{head}</code>) and on the pull request."
684            )
685        }
686    }
687}
688
689/// Pick the panel's language. Codes and names both, because `[graph] language`
690/// has always accepted either.
691fn words(language: &str) -> &'static Words {
692    let l = language.trim();
693    if l.eq_ignore_ascii_case("ja")
694        || l.eq_ignore_ascii_case("jp")
695        || l.eq_ignore_ascii_case("japanese")
696        || l.eq_ignore_ascii_case("日本語")
697    {
698        &JA
699    } else {
700        &EN
701    }
702}
703
704/// The approval panel's html: what is about to land, and the evidence for it.
705///
706/// Pure, so the whole document is asserted in tests without `gh`, without a
707/// network and without a repository. The caller gathers `diffstat`
708/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
709/// being squashed) and `subject` (what the squash will be called) from the
710/// winner's worktree.
711///
712/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
713/// content security policy blocks all three: anything of the sort here would be
714/// dead markup that misleads the next reader into thinking it works.
715pub fn approval_panel(
716    state: &RunState,
717    pr: &PrState,
718    diffstat: &str,
719    diff: &str,
720    commits: &[String],
721    subject: &str,
722) -> String {
723    let rows = parse_numstat(diffstat);
724    let w = words(&state.config.graph.language);
725    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
726
727    let _ = writeln!(
728        h,
729        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
730         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
731        w.html_lang
732    );
733    let _ = writeln!(
734        h,
735        "<title>merge #{} — {}</title>\n</head>",
736        pr.number,
737        esc(subject)
738    );
739    h.push_str(
740        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
741         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
742         word-break:break-word\">\n",
743    );
744
745    // The decision, in the words the operator is approving.
746    let _ = writeln!(
747        h,
748        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
749         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
750         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
751         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
752         <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
753        pr.number,
754        esc(&state.base_branch),
755        esc(subject),
756        esc(&state.id),
757        esc(&pr.url),
758        esc(&pr.url),
759    );
760
761    let _ = writeln!(
762        h,
763        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
764        w.checks,
765        esc(pr.checks.as_str())
766    );
767    if pr.failing.is_empty() {
768        let _ = writeln!(
769            h,
770            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
771            w.nothing_failing
772        );
773    } else {
774        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
775        for f in &pr.failing {
776            let _ = writeln!(h, "<li>{}</li>", esc(f));
777        }
778        h.push_str("</ul>\n");
779    }
780
781    // Diffstat as a real table, so a phone reads what moved without scrolling
782    // sideways through a terminal bar chart.
783    let _ = writeln!(
784        h,
785        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
786        rows.len(),
787        w.files_changed
788    );
789    h.push_str(
790        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
791         <thead><tr>\
792         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
793         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
794         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
795         </th></tr></thead>\n<tbody>\n",
796    );
797    let mut total_added = 0u64;
798    let mut total_removed = 0u64;
799    for r in &rows {
800        total_added += r.added.unwrap_or(0);
801        total_removed += r.removed.unwrap_or(0);
802        let cell = |n: Option<u64>| match n {
803            Some(n) => n.to_string(),
804            None => "bin".to_owned(),
805        };
806        let _ = writeln!(
807            h,
808            "<tr>\
809             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
810             font-family:ui-monospace,monospace\">{}</td>\
811             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
812             color:#0a3622\">{}</td>\
813             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
814             color:#5c1a17\">{}</td></tr>",
815            esc(&r.path),
816            cell(r.added),
817            cell(r.removed),
818        );
819    }
820    let _ = writeln!(
821        h,
822        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
823         <td style=\"padding:4px 2px\">total</td>\
824         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
825         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
826         </tr></tfoot>\n</table>"
827    );
828
829    // The commits being squashed, and the subject that replaces them.
830    let _ = writeln!(
831        h,
832        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
833        w.commits
834    );
835    if commits.is_empty() {
836        h.push_str(&format!(
837            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
838            w.no_commits
839        ));
840    } else {
841        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
842        for c in commits {
843            let _ = writeln!(h, "<li>{}</li>", esc(c));
844        }
845        h.push_str("</ol>\n");
846    }
847    let _ = writeln!(
848        h,
849        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
850        w.lands_as,
851        esc(subject),
852        w.lands_as_tail()
853    );
854
855    // The review comments that shaped this branch, and who asked for them.
856    let _ = writeln!(
857        h,
858        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
859        w.comments
860    );
861    if pr.review_comments.is_empty() {
862        h.push_str(&format!(
863            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
864            w.no_comments
865        ));
866    } else {
867        for c in &pr.review_comments {
868            let anchor = match (&c.path, c.line) {
869                (Some(p), Some(l)) => format!("{p}:{l}"),
870                (Some(p), None) => p.clone(),
871                _ => "pull request thread".to_owned(),
872            };
873            let _ = writeln!(
874                h,
875                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
876                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
877                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
878                esc(&c.author),
879                esc(&anchor),
880                esc(&tail(&c.body, 800)),
881            );
882        }
883    }
884
885    // The patch itself.
886    let total = diff.lines().count();
887    let shown = total.min(DIFF_MAX_LINES);
888    let _ = writeln!(
889        h,
890        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
891        w.diff
892    );
893    h.push_str(
894        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
895         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
896    );
897    for line in diff.lines().take(shown) {
898        let (gutter, style, body) = diff_row(line);
899        let _ = writeln!(
900            h,
901            "<div style=\"display:flex;{style}\">\
902             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
903             border-right:1px solid #d0d7de\">{gutter}</span>\
904             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
905            esc(body),
906        );
907    }
908    h.push_str("</div>\n");
909    if total > shown {
910        let omitted = total - shown;
911        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
912        let where_ = state.winner().map_or_else(
913            || state.repo.display().to_string(),
914            |w| w.worktree.display().to_string(),
915        );
916        let _ = writeln!(
917            h,
918            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
919             font-size:13px\">{}: {}</p>",
920            w.truncated,
921            w.truncated_note(
922                omitted,
923                total,
924                shown,
925                &esc(&where_),
926                &esc(&state.base_branch),
927                &esc(head),
928            ),
929        );
930    }
931
932    h.push_str("</body>\n</html>\n");
933    h
934}
935
936/// Ask the owner before merging, with the whole case attached as a panel.
937///
938/// The evidence is gathered from the winner's own worktree with the `git` CLI,
939/// never from the network, so a phone on a slow link gets the diff magi is
940/// looking at rather than a link it has to go and open.
941async fn request_approval(state: &mut RunState, pr: &PrState, subject: &str) -> Result<Approval> {
942    let (worktree, head) = match state.winner() {
943        Some(w) => (w.worktree.clone(), w.branch.clone()),
944        None => (state.repo.clone(), "HEAD".to_owned()),
945    };
946    let base = state.base_branch.clone();
947    let range = format!("{base}...{head}");
948    // A failed `git` must not decide the merge: the panel degrades to less
949    // evidence and the owner still chooses. Merging because the diff could not
950    // be read would be the worst of both.
951    let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
952        .await
953        .map(|o| o.stdout)
954        .unwrap_or_default();
955    let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
956    let commits: Vec<String> = git::git_raw(
957        &worktree,
958        &[
959            "log",
960            "--reverse",
961            "--format=%s",
962            &format!("{base}..{head}"),
963        ],
964    )
965    .await
966    .map(|o| o.stdout)
967    .unwrap_or_default()
968    .lines()
969    .filter(|l| !l.trim().is_empty())
970    .map(str::to_owned)
971    .collect();
972
973    let w = words(&state.config.graph.language);
974    let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
975    let store = ask::Questions::open();
976    let mut q = ask::Question::new(
977        state.id.clone(),
978        APPROVAL_NODE.to_owned(),
979        "land".to_owned(),
980        w.approval_summary(pr.number, subject),
981        w.approval_detail(&pr.url, &base, subject),
982        vec![APPROVE.to_owned(), HOLD.to_owned()],
983    );
984    store
985        .put_panel(&mut q, &html, &[])
986        .context("write the merge approval panel")?;
987    state.event("land", format!("asking for merge approval ({})", q.short()));
988    state.save()?;
989
990    let timeout = Duration::from_secs(state.config.graph.answer_timeout);
991    let said = ask::ask_and_wait(&mut q, &store, &state.config.notify, timeout).await?;
992    Ok(approval(said.as_deref()))
993}
994
995/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
996/// output into a [`PrState`]. No I/O.
997pub fn parse_pr(json: &str) -> Result<PrState> {
998    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
999    let state = match raw.state.to_ascii_uppercase().as_str() {
1000        "OPEN" => PrLifecycle::Open,
1001        "MERGED" => PrLifecycle::Merged,
1002        "CLOSED" => PrLifecycle::Closed,
1003        other => bail!("unknown pull request state `{other}`"),
1004    };
1005
1006    let mut failing = Vec::new();
1007    let mut pending = false;
1008    let mut unknown = false;
1009    for check in &raw.status_check_rollup {
1010        match check.verdict() {
1011            Verdict::Pass => {}
1012            Verdict::Pending => pending = true,
1013            Verdict::Fail => failing.push(check.label()),
1014            Verdict::Unknown => unknown = true,
1015        }
1016    }
1017    let checks = if raw.status_check_rollup.is_empty() {
1018        Checks::Unknown
1019    } else if pending {
1020        Checks::Pending
1021    } else if !failing.is_empty() {
1022        Checks::Red
1023    } else if unknown {
1024        Checks::Unknown
1025    } else {
1026        Checks::Green
1027    };
1028
1029    let mut review_comments = Vec::new();
1030    for r in raw.reviews {
1031        push_if_outstanding(
1032            &mut review_comments,
1033            ReviewComment {
1034                author: r.author.login,
1035                path: None,
1036                line: None,
1037                body: r.body,
1038            },
1039        );
1040    }
1041    for c in raw.comments {
1042        push_if_outstanding(
1043            &mut review_comments,
1044            ReviewComment {
1045                author: c.author.login,
1046                path: None,
1047                line: None,
1048                body: c.body,
1049            },
1050        );
1051    }
1052
1053    Ok(PrState {
1054        url: raw.url,
1055        number: raw.number,
1056        state,
1057        checks,
1058        failing,
1059        review_comments,
1060        blocking: Blocking::of(&raw.merge_state_status),
1061    })
1062}
1063
1064/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
1065/// comments. No I/O.
1066///
1067/// `gh pr view` does not surface inline comments, and inline is exactly where
1068/// both review bots put their findings - a landing loop that read only the
1069/// top-level thread would never see the thing it is supposed to fix.
1070pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1071    let raw: Vec<GhInline> =
1072        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1073    let mut out = Vec::new();
1074    for c in raw {
1075        push_if_outstanding(
1076            &mut out,
1077            ReviewComment {
1078                author: c.user.login,
1079                path: c.path,
1080                line: c.line,
1081                body: c.body,
1082            },
1083        );
1084    }
1085    Ok(out)
1086}
1087
1088/// Keep a comment only when it asks for something.
1089///
1090/// An inline comment always does: it names a file and a line. A top-level
1091/// comment is dropped when it is empty, when it is magi's own, or when it is
1092/// [noise](is_noise).
1093fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1094    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1095        return;
1096    }
1097    if comment.path.is_none() && is_noise(&comment.body) {
1098        return;
1099    }
1100    out.push(comment);
1101}
1102
1103/// Is this comment body machinery rather than a finding?
1104///
1105/// Two tests, both structural, because guessing from prose is how a "looks
1106/// good to me" turns into a fix round:
1107///
1108/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
1109///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
1110///    footer.
1111/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
1112///    horizontal rules, and the bot's own status banner are removed, every
1113///    remaining line is a task-list item. That is exactly the shape of the
1114///    comment the Claude review job posts while it is still working.
1115///
1116/// Anything else is input, including bot prose. A bot that writes a paragraph
1117/// has said something, and the fix prompt tells the fixer it may decline a
1118/// comment with an argument - a wasted sentence in a prompt is cheaper than a
1119/// missed finding.
1120pub fn is_noise(body: &str) -> bool {
1121    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1122        return true;
1123    }
1124    let mut content = false;
1125    for line in strip_blocks(body).lines() {
1126        let line = unquote(line);
1127        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1128            continue;
1129        }
1130        content = true;
1131        break;
1132    }
1133    !content
1134}
1135
1136/// Remove HTML comments and collapsed `<details>` blocks.
1137fn strip_blocks(body: &str) -> String {
1138    let mut out = String::with_capacity(body.len());
1139    let mut rest = body;
1140    loop {
1141        let open = ["<!--", "<details>"]
1142            .iter()
1143            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1144            .min_by_key(|(i, _)| *i);
1145        let Some((at, tag)) = open else {
1146            out.push_str(rest);
1147            return out;
1148        };
1149        out.push_str(&rest[..at]);
1150        let after = &rest[at + tag.len()..];
1151        let close = if tag == "<!--" { "-->" } else { "</details>" };
1152        match after.find(close) {
1153            Some(end) => rest = &after[end + close.len()..],
1154            // Unterminated: the rest of the body is inside the block.
1155            None => return out,
1156        }
1157    }
1158}
1159
1160/// Strip blockquote markers, which both bots wrap their callouts in.
1161fn unquote(line: &str) -> &str {
1162    let mut s = line.trim();
1163    while let Some(rest) = s.strip_prefix('>') {
1164        s = rest.trim_start();
1165    }
1166    s.trim()
1167}
1168
1169/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
1170fn is_checklist(line: &str) -> bool {
1171    let rest = line
1172        .strip_prefix("- ")
1173        .or_else(|| line.strip_prefix("* "))
1174        .unwrap_or("");
1175    let rest = rest.trim_start();
1176    matches!(
1177        rest.get(..3),
1178        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1179    )
1180}
1181
1182/// A heading, a horizontal rule, or a callout tag - shape, never content.
1183fn is_decoration(line: &str) -> bool {
1184    line.starts_with('#')
1185        || line.starts_with("[!")
1186        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1187}
1188
1189/// A line that is nothing but emphasis and links.
1190///
1191/// Both review jobs open with a status banner
1192/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
1193/// to a line-based test and asks for nothing, so it is measured the same way a
1194/// heading is: strip the markup, and if no word survives, it was decoration.
1195fn is_banner(line: &str) -> bool {
1196    let plain = drop_spans(line, "**", "**");
1197    let plain = if plain.contains("](") {
1198        drop_spans(&plain, "[", ")")
1199    } else {
1200        plain
1201    };
1202    !plain.chars().any(char::is_alphanumeric)
1203}
1204
1205/// Remove every `open` .. `close` span, including the delimiters. An
1206/// unterminated span swallows the rest of the input, which is what a reader
1207/// sees too.
1208fn drop_spans(s: &str, open: &str, close: &str) -> String {
1209    let mut out = String::with_capacity(s.len());
1210    let mut rest = s;
1211    while let Some(at) = rest.find(open) {
1212        out.push_str(&rest[..at]);
1213        let after = &rest[at + open.len()..];
1214        match after.find(close) {
1215            Some(end) => rest = &after[end + close.len()..],
1216            None => return out,
1217        }
1218    }
1219    out.push_str(rest);
1220    out
1221}
1222
1223/// Run the loop against a real pull request until it merges or the budget runs
1224/// out.
1225///
1226/// The caller decides whether landing happens at all: this is only reached when
1227/// `graph.land` is on. Returns the last observation, so the caller can report
1228/// what magi was looking at when it stopped.
1229pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1230    let repo = state.repo.clone();
1231    let budget = state.config.graph.land_rounds;
1232    let mut round = 0usize;
1233    // Counted apart from `round`: a rebase is not a fix, and a base that
1234    // moved is not the change's fault.
1235    let mut rebases = 0usize;
1236    let mut waited = Duration::ZERO;
1237    // Comment bodies the fixer has already been shown. A comment is
1238    // outstanding until it has been handed over once; after that it is a
1239    // recorded decision, not an open question, and re-feeding it would loop the
1240    // budget away on a comment the fixer already declined with an argument.
1241    let mut shown: BTreeSet<String> = BTreeSet::new();
1242
1243    state.event("land", format!("watching {pr_url}"));
1244    state.save()?;
1245
1246    loop {
1247        let seen = observe(&repo, pr_url).await?;
1248        let mut pr = seen.pr;
1249        pr.review_comments.retain(|c| !shown.contains(&c.body));
1250        state.pr = Some(crate::run::PrRecord {
1251            url: pr.url.clone(),
1252            number: pr.number,
1253            state: pr.state.as_str().to_owned(),
1254            checks: pr.checks.as_str().to_owned(),
1255            round,
1256            rounds: budget,
1257        });
1258        state.save()?;
1259
1260        match decide(&pr, round, budget, waited) {
1261            Step::Wait => {
1262                if waited >= WAIT_CEILING {
1263                    let why = format!(
1264                        "checks were still running after {} minutes",
1265                        WAIT_CEILING.as_secs() / 60
1266                    );
1267                    stop(state, &repo, &pr, &why).await?;
1268                    return Ok(pr);
1269                }
1270                waited += POLL;
1271                tokio::time::sleep(POLL).await;
1272            }
1273            Step::Done { merged } => {
1274                state.status = if merged {
1275                    RunStatus::Merged
1276                } else {
1277                    RunStatus::Ready
1278                };
1279                let detail = if merged {
1280                    format!("{} was merged", pr.url)
1281                } else {
1282                    format!("{} was closed without merging", pr.url)
1283                };
1284                state.merge = Some(MergeOutcome {
1285                    mode: MergeMode::Pr,
1286                    ok: merged,
1287                    detail: detail.clone(),
1288                });
1289                state.event("land", detail);
1290                state.save()?;
1291                return Ok(pr);
1292            }
1293            Step::Merge => {
1294                let subject = merge_subject(&seen.title, &state.instruction);
1295                // The owner sees the panel before the one irreversible step,
1296                // and an unanswered question is a hold: silence never merges.
1297                if state.config.graph.land_approval
1298                    && request_approval(state, &pr, &subject).await? == Approval::Hold
1299                {
1300                    stop(
1301                        state,
1302                        &repo,
1303                        &pr,
1304                        "the owner did not approve the merge (held or unanswered)",
1305                    )
1306                    .await?;
1307                    return Ok(pr);
1308                }
1309                let argv = merge_argv(pr.number, &subject);
1310                let out = gh(&repo, &argv).await?;
1311                if out.0 {
1312                    state.status = RunStatus::Merged;
1313                    state.merge = Some(MergeOutcome {
1314                        mode: MergeMode::Pr,
1315                        ok: true,
1316                        detail: format!("gh {}", argv.join(" ")),
1317                    });
1318                    state.event("land", format!("merged {} as `{subject}`", pr.url));
1319                    state.save()?;
1320                    pr.state = PrLifecycle::Merged;
1321                    return Ok(pr);
1322                }
1323                let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
1324                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
1325                    state.status = RunStatus::Merged;
1326                    state.merge = Some(outcome);
1327                    state.event("land", format!("merged {} as `{subject}`", pr.url));
1328                    state.save()?;
1329                    pr.state = PrLifecycle::Merged;
1330                    return Ok(pr);
1331                }
1332                stop(
1333                    state,
1334                    &repo,
1335                    &pr,
1336                    &format!("`gh pr merge` failed: {}", out.1),
1337                )
1338                .await?;
1339                return Ok(pr);
1340            }
1341            Step::Rebase => {
1342                // Bounded by the same budget as a fix, because a rebase that
1343                // keeps being needed means the base moves faster than this
1344                // run can land and a person should decide what to do. It
1345                // spends none of that budget: the change is not what is
1346                // wrong.
1347                if rebases >= budget {
1348                    let why = format!(
1349                        "the base moved under this branch {budget} time(s) and it still does \
1350                         not merge; rebasing again would only race it"
1351                    );
1352                    stop(state, &repo, &pr, &why).await?;
1353                    return Ok(pr);
1354                }
1355                rebases += 1;
1356                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
1357                    stop(
1358                        state,
1359                        &repo,
1360                        &pr,
1361                        "the pull request conflicts and this run has no winning branch to rebase",
1362                    )
1363                    .await?;
1364                    return Ok(pr);
1365                };
1366                let base = state.base_branch.clone();
1367                state.event(
1368                    "land",
1369                    format!("{} no longer merges; rebasing onto {base}", pr.url),
1370                );
1371                state.save()?;
1372
1373                // Onto the base as the *remote* has it: the local ref may be
1374                // behind, and rebasing onto a stale base produces a branch
1375                // that conflicts all over again.
1376                git::fetch(&repo, "origin", &base).await.ok();
1377                let scratch = state.dir().join("rebase");
1378                let onto = format!("origin/{base}");
1379                match git::rebase_branch_in_temp(&repo, &scratch, &branch, &onto).await {
1380                    Ok(None) => {
1381                        let pushed = git::push_rewritten(&repo, "origin", &branch).await?;
1382                        if !pushed.ok() {
1383                            let why = format!(
1384                                "rebased {branch} but could not push it: {}",
1385                                pushed.stderr.trim()
1386                            );
1387                            stop(state, &repo, &pr, &why).await?;
1388                            return Ok(pr);
1389                        }
1390                        state.event("land", format!("rebased {branch} onto {base}"));
1391                        state.save()?;
1392                        // The forge has to re-run its checks against the
1393                        // rebased head before anything else can be decided.
1394                        waited = Duration::ZERO;
1395                        tokio::time::sleep(POLL).await;
1396                    }
1397                    // A conflict is a decision, not a chore.
1398                    Ok(Some(conflict)) => {
1399                        let why = format!(
1400                            "{} conflicts with {base} and the rebase did not apply: {}",
1401                            pr.url,
1402                            conflict.chars().take(600).collect::<String>()
1403                        );
1404                        stop(state, &repo, &pr, &why).await?;
1405                        return Ok(pr);
1406                    }
1407                    Err(e) => {
1408                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
1409                        stop(state, &repo, &pr, &why).await?;
1410                        return Ok(pr);
1411                    }
1412                }
1413            }
1414            Step::GiveUp { reason } => {
1415                stop(state, &repo, &pr, &reason).await?;
1416                return Ok(pr);
1417            }
1418            Step::Fix { reason } => {
1419                round += 1;
1420                waited = Duration::ZERO;
1421                for c in &pr.review_comments {
1422                    shown.insert(c.body.clone());
1423                }
1424                state.event("land", format!("round {round}: {reason}"));
1425                state.save()?;
1426
1427                let logs = failing_logs(&repo, &seen.failing_urls).await;
1428                let was_red = pr.checks == Checks::Red;
1429                match fix_round(state, &pr, round, budget, &reason, &logs).await? {
1430                    Fixed::Committed => {}
1431                    Fixed::Declined if was_red => {
1432                        let why = format!(
1433                            "the fixer produced no commit while {} check(s) were failing; \
1434                             stopping instead of looping on an unchanged tree",
1435                            pr.failing.len()
1436                        );
1437                        stop(state, &repo, &pr, &why).await?;
1438                        return Ok(pr);
1439                    }
1440                    // Comment-driven round with no commit: the fixer read the
1441                    // comments and changed nothing, which is a decision it is
1442                    // allowed to make. The comments are recorded as shown, so
1443                    // the next observation sees a clean pull request.
1444                    Fixed::Declined => state.event(
1445                        "land",
1446                        format!("round {round}: fixer declined the comments, nothing committed"),
1447                    ),
1448                    Fixed::Failed(why) => {
1449                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
1450                        return Ok(pr);
1451                    }
1452                }
1453                state.save()?;
1454            }
1455        }
1456    }
1457}
1458
1459/// One observation, plus the two things [`PrState`] deliberately does not carry:
1460/// the title (needed for the squash subject) and where the failing checks'
1461/// logs live.
1462struct Seen {
1463    pr: PrState,
1464    title: String,
1465    failing_urls: Vec<(String, String)>,
1466}
1467
1468/// Read the pull request: `gh pr view` for the rollup and the top-level thread,
1469/// `gh api` for the inline review comments `gh pr view` does not report.
1470async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
1471    let view = gh(
1472        repo,
1473        &[
1474            "pr".to_owned(),
1475            "view".to_owned(),
1476            pr_url.to_owned(),
1477            "--json".to_owned(),
1478            "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
1479        ],
1480    )
1481    .await?;
1482    if !view.0 {
1483        bail!("gh pr view {pr_url}: {}", view.1);
1484    }
1485    let mut pr = parse_pr(&view.1)?;
1486    let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
1487
1488    let inline = gh(
1489        repo,
1490        &[
1491            "api".to_owned(),
1492            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
1493        ],
1494    )
1495    .await?;
1496    if inline.0 {
1497        match parse_inline_comments(&inline.1) {
1498            Ok(mut comments) => pr.review_comments.append(&mut comments),
1499            // An unreadable inline thread must not end a landing: the rollup
1500            // and the top-level thread are still real signal.
1501            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
1502        }
1503    } else {
1504        tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
1505    }
1506
1507    let failing_urls = raw
1508        .status_check_rollup
1509        .iter()
1510        .filter(|c| c.verdict() == Verdict::Fail)
1511        .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
1512        .collect();
1513
1514    Ok(Seen {
1515        pr,
1516        title: raw.title,
1517        failing_urls,
1518    })
1519}
1520
1521/// What a fix round did.
1522enum Fixed {
1523    /// The fixer committed something.
1524    Committed,
1525    /// The fixer ran and chose to change nothing.
1526    Declined,
1527    /// The fixer could not run, or said nothing usable.
1528    Failed(String),
1529}
1530
1531/// Hand the failures and the comments to the fixer, then commit and push.
1532///
1533/// The fixer works in the winner's own worktree so its commits land on the
1534/// branch the pull request is built from, and it runs with `allow_write` for
1535/// the same reason.
1536async fn fix_round(
1537    state: &mut RunState,
1538    pr: &PrState,
1539    round: usize,
1540    budget: usize,
1541    reason: &str,
1542    logs: &str,
1543) -> Result<Fixed> {
1544    let winner = state
1545        .winner()
1546        .cloned()
1547        .context("landing needs a winning candidate; none is recorded on this run")?;
1548    let roles = state
1549        .config
1550        .resolve_roles()
1551        .context("resolve the roster for the fix round")?;
1552    // Same rule as the review loop: an explicitly configured fixer, otherwise
1553    // the winner's own author continuing its own conversation - the competition
1554    // is over, so its context is pure benefit.
1555    let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
1556        Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1557        _ => (
1558            state
1559                .config
1560                .agent(&winner.agent)
1561                .cloned()
1562                .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
1563            format!("impl-{}", winner.label),
1564        ),
1565    };
1566
1567    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
1568    let mut seat = seat_of(state, &seat_key, &spec.id);
1569    let artifacts = agent::artifacts_dir(&state.dir());
1570    let out = agent::invoke(
1571        &spec,
1572        &mut seat,
1573        &Invocation {
1574            cwd: &winner.worktree,
1575            prompt: &prompt,
1576            timeout: Duration::from_secs(state.config.graph.timeout_fix),
1577            allow_write: true,
1578            sessions: state.config.graph.sessions,
1579            artifacts: &artifacts,
1580            stem: &format!("land-{round}"),
1581            run: &state.id,
1582            node: "land",
1583        },
1584    )
1585    .await;
1586    state.seats.insert(seat.key.clone(), seat);
1587
1588    match out {
1589        Ok(o) if o.quota_exhausted() => {
1590            return Ok(Fixed::Failed(
1591                "rate limited (quota); the fixer could not run".to_owned(),
1592            ));
1593        }
1594        Ok(o) if !o.usable() => {
1595            return Ok(Fixed::Failed(format!(
1596                "the fixer produced nothing usable (exit {:?}, timed out: {})",
1597                o.exit_code, o.timed_out
1598            )));
1599        }
1600        Ok(_) => {}
1601        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
1602    }
1603
1604    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1605    // An agent that edited files but never committed would otherwise push
1606    // nothing and look like a refusal.
1607    git::commit_all(
1608        &winner.worktree,
1609        &format!("magi: land round {round} fixes (uncommitted work)"),
1610    )
1611    .await
1612    .ok();
1613    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
1614    if after == before {
1615        return Ok(Fixed::Declined);
1616    }
1617
1618    let remote = state.config.merge.remote.clone();
1619    let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
1620    if !push.ok() {
1621        return Ok(Fixed::Failed(format!(
1622            "pushing {} to {remote} failed: {}",
1623            winner.branch, push.stderr
1624        )));
1625    }
1626    state.event(
1627        "land",
1628        format!("round {round}: pushed a fix to {}", winner.branch),
1629    );
1630    Ok(Fixed::Committed)
1631}
1632
1633/// Fetch or create a seat, keeping its conversation across nodes.
1634fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
1635    if let Some(existing) = state.seats.get(key)
1636        && existing.agent == agent
1637    {
1638        return existing.clone();
1639    }
1640    let fresh = SeatState::new(key, agent, state.seed);
1641    state.seats.insert(key.to_owned(), fresh.clone());
1642    fresh
1643}
1644
1645/// What the fixer is told.
1646fn fix_prompt(
1647    state: &RunState,
1648    pr: &PrState,
1649    round: usize,
1650    budget: usize,
1651    reason: &str,
1652    logs: &str,
1653) -> String {
1654    let mut s = format!(
1655        "Your patch is open as a pull request and it is not landing. Land round \
1656         {round} of {budget}.\n\n\
1657         Pull request: {}\n\n\
1658         What is holding it: {reason}\n\n\
1659         # The task\n\n{}\n",
1660        pr.url, state.instruction
1661    );
1662
1663    if pr.failing.is_empty() {
1664        s.push_str("\n# Failing checks\n\n(none)\n");
1665    } else {
1666        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
1667        if logs.trim().is_empty() {
1668            s.push_str("\nNo log could be read; reproduce the failure locally.\n");
1669        } else {
1670            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
1671        }
1672    }
1673
1674    if pr.review_comments.is_empty() {
1675        s.push_str("\n# Review comments\n\n(none)\n");
1676    } else {
1677        s.push_str("\n# Review comments\n");
1678        for c in &pr.review_comments {
1679            let where_ = match (&c.path, c.line) {
1680                (Some(p), Some(l)) => format!(" ({p}:{l})"),
1681                (Some(p), None) => format!(" ({p})"),
1682                _ => String::new(),
1683            };
1684            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
1685        }
1686    }
1687
1688    s.push_str(
1689        "\n# Rules\n\n\
1690         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
1691            failing test; do not silence a lint with an allow attribute; do not \
1692            stretch a timeout to hide a race. If the check is right, the code is \
1693            wrong.\n\
1694         2. Change nothing the checks and the comments did not raise. A \
1695            drive-by refactor turns a one-line fix into a pull request that \
1696            needs reviewing again.\n\
1697         3. If a comment is wrong, say so with a checkable argument and change \
1698            nothing for it. A declined comment with a reason is a correct \
1699            outcome; a change made to appease a reviewer is not.\n\
1700         4. Commit in this worktree. magi pushes to the pull request's branch \
1701            for you; do not push, merge, or close anything yourself.\n\
1702         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
1703         # Output\n\n\
1704         Say what you changed and why, and what you declined and why.",
1705    );
1706
1707    let language = &state.config.graph.language;
1708    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
1709        let _ = write!(s, "\n\nWrite all prose in {language}.");
1710    }
1711    if let Some(overlay) = state.config.prompts.overlay("fix") {
1712        let _ = write!(s, "\n\n{overlay}");
1713    }
1714    s
1715}
1716
1717/// Failing log tails, the way the operator collects them by hand:
1718/// `gh run view --log-failed`.
1719async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
1720    let mut out = String::new();
1721    for (name, url) in failing.iter().take(MAX_LOGS) {
1722        let args = match (job_of(url), run_of(url)) {
1723            (Some(job), _) => vec![
1724                "run".to_owned(),
1725                "view".to_owned(),
1726                "--log-failed".to_owned(),
1727                "--job".to_owned(),
1728                job,
1729            ],
1730            (None, Some(run)) => vec![
1731                "run".to_owned(),
1732                "view".to_owned(),
1733                run,
1734                "--log-failed".to_owned(),
1735            ],
1736            // Not a GitHub Actions check - an external status has no log here.
1737            (None, None) => continue,
1738        };
1739        let (ok, body) = match gh(repo, &args).await {
1740            Ok(v) => v,
1741            Err(e) => (false, format!("{e:#}")),
1742        };
1743        if !ok && body.trim().is_empty() {
1744            continue;
1745        }
1746        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
1747    }
1748    out
1749}
1750
1751/// Job id out of a check's `detailsUrl`
1752/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
1753fn job_of(details_url: &str) -> Option<String> {
1754    let after = details_url.split("/job/").nth(1)?;
1755    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1756    (!id.is_empty()).then_some(id)
1757}
1758
1759/// Workflow run id out of a check's `detailsUrl`.
1760fn run_of(details_url: &str) -> Option<String> {
1761    let after = details_url.split("/actions/runs/").nth(1)?;
1762    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
1763    (!id.is_empty()).then_some(id)
1764}
1765
1766/// Leave the pull request open, say why on it, and mark the run blocked.
1767///
1768/// The comment is what makes an unattended stop actionable: the operator wakes
1769/// up to a pull request that explains itself rather than to a silent queue.
1770async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
1771    let body = format!(
1772        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
1773         The branch is untouched and the run is `{}`. Nothing was merged.",
1774        state.id
1775    );
1776    let posted = gh(
1777        repo,
1778        &[
1779            "pr".to_owned(),
1780            "comment".to_owned(),
1781            pr.number.to_string(),
1782            "--body".to_owned(),
1783            body,
1784        ],
1785    )
1786    .await;
1787    match posted {
1788        Ok((true, _)) => {}
1789        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
1790        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
1791    }
1792    state.status = RunStatus::Blocked;
1793    state.merge = Some(MergeOutcome {
1794        mode: MergeMode::Pr,
1795        ok: false,
1796        detail: why.to_owned(),
1797    });
1798    state.event("land", format!("stopped: {why}"));
1799    state.save()?;
1800    Ok(())
1801}
1802
1803/// Run `gh` in `repo`, returning success and the combined output.
1804///
1805/// Combined because `gh` reports a refused merge on stderr and the pull request
1806/// json on stdout, and both are evidence.
1807async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
1808    let out = tokio::process::Command::new("gh")
1809        .args(args)
1810        .current_dir(cwd)
1811        .stdin(std::process::Stdio::null())
1812        .output()
1813        .await
1814        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
1815    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
1816    let err = String::from_utf8_lossy(&out.stderr);
1817    if body.trim().is_empty() {
1818        body = err.into_owned();
1819    } else if !err.trim().is_empty() {
1820        body.push_str(&err);
1821    }
1822    Ok((out.status.success(), body.trim().to_owned()))
1823}
1824
1825/// Verdict of one entry in the status rollup.
1826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827enum Verdict {
1828    Pass,
1829    Fail,
1830    Pending,
1831    Unknown,
1832}
1833
1834#[derive(Debug, Deserialize)]
1835#[serde(rename_all = "camelCase")]
1836struct GhPr {
1837    #[serde(default)]
1838    url: String,
1839    #[serde(default)]
1840    number: u64,
1841    #[serde(default)]
1842    state: String,
1843    #[serde(default)]
1844    title: String,
1845    #[serde(default)]
1846    status_check_rollup: Vec<GhCheck>,
1847    /// GitHub's own verdict on whether the pull request can be merged.
1848    ///
1849    /// Worth asking for because it is the only place the *required* check set
1850    /// is applied: the rollup lists every check equally, so a repository that
1851    /// deliberately does not require `coverage` still looks red here. See
1852    /// [`Blocking`].
1853    #[serde(default)]
1854    merge_state_status: String,
1855    #[serde(default)]
1856    reviews: Vec<GhReview>,
1857    #[serde(default)]
1858    comments: Vec<GhComment>,
1859}
1860
1861/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
1862/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
1863/// status API, which is how CodeRabbit reports - has `context`/`state` and no
1864/// conclusion at all.
1865#[derive(Debug, Deserialize)]
1866#[serde(rename_all = "camelCase")]
1867struct GhCheck {
1868    #[serde(default)]
1869    name: Option<String>,
1870    #[serde(default)]
1871    context: Option<String>,
1872    #[serde(default)]
1873    status: Option<String>,
1874    #[serde(default)]
1875    conclusion: Option<String>,
1876    #[serde(default)]
1877    state: Option<String>,
1878    #[serde(default)]
1879    details_url: Option<String>,
1880    #[serde(default)]
1881    target_url: Option<String>,
1882}
1883
1884impl GhCheck {
1885    /// Name to show a human and hand to the fixer.
1886    fn label(&self) -> String {
1887        self.name
1888            .clone()
1889            .or_else(|| self.context.clone())
1890            .unwrap_or_else(|| "(unnamed check)".to_owned())
1891    }
1892
1893    /// Where this check's logs live, when it has any.
1894    fn url(&self) -> Option<&str> {
1895        self.details_url
1896            .as_deref()
1897            .or(self.target_url.as_deref())
1898            .filter(|u| !u.is_empty())
1899    }
1900
1901    /// Did it pass?
1902    ///
1903    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
1904    /// skips release and bot pull requests by design, and a skip that blocked
1905    /// landing would block exactly the pull requests that need no review.
1906    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
1907    /// merging over one is merging over a check that never ran.
1908    fn verdict(&self) -> Verdict {
1909        if let Some(status) = self.status.as_deref() {
1910            if !status.eq_ignore_ascii_case("COMPLETED") {
1911                return Verdict::Pending;
1912            }
1913        }
1914        let outcome = self
1915            .conclusion
1916            .as_deref()
1917            .or(self.state.as_deref())
1918            .unwrap_or("");
1919        match outcome.to_ascii_uppercase().as_str() {
1920            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
1921            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
1922            | "ACTION_REQUIRED" => Verdict::Fail,
1923            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
1924                Verdict::Pending
1925            }
1926            _ => Verdict::Unknown,
1927        }
1928    }
1929}
1930
1931#[derive(Debug, Deserialize)]
1932struct GhAuthor {
1933    #[serde(default)]
1934    login: String,
1935}
1936
1937#[derive(Debug, Deserialize)]
1938struct GhReview {
1939    #[serde(default)]
1940    author: GhAuthor,
1941    #[serde(default)]
1942    body: String,
1943}
1944
1945#[derive(Debug, Deserialize)]
1946struct GhComment {
1947    #[serde(default)]
1948    author: GhAuthor,
1949    #[serde(default)]
1950    body: String,
1951}
1952
1953#[derive(Debug, Deserialize)]
1954struct GhUser {
1955    #[serde(default)]
1956    login: String,
1957}
1958
1959#[derive(Debug, Deserialize)]
1960struct GhInline {
1961    #[serde(default)]
1962    user: GhUser,
1963    #[serde(default)]
1964    path: Option<String>,
1965    #[serde(default)]
1966    line: Option<u64>,
1967    #[serde(default)]
1968    body: String,
1969}
1970
1971impl Default for GhAuthor {
1972    fn default() -> Self {
1973        Self {
1974            login: "(unknown)".to_owned(),
1975        }
1976    }
1977}
1978
1979impl Default for GhUser {
1980    fn default() -> Self {
1981        Self {
1982            login: "(unknown)".to_owned(),
1983        }
1984    }
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989    use super::*;
1990
1991    /// Real `gh pr view` output for the open pull request #10 (Renovate's apm bump), trimmed to four checks and its one comment. Every check passed or was skipped by the review workflow, and the only comment is CodeRabbit's trigger notice.
1992    const GREEN_OPEN: &str = r####"{
1993  "url": "https://github.com/yukimemi/magi/pull/10",
1994  "number": 10,
1995  "state": "OPEN",
1996  "mergeStateStatus": "CLEAN",
1997  "statusCheckRollup": [
1998    {
1999      "__typename": "CheckRun",
2000      "conclusion": "SKIPPED",
2001      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2002      "name": "review",
2003      "status": "COMPLETED",
2004      "workflowName": "claude-review"
2005    },
2006    {
2007      "__typename": "CheckRun",
2008      "conclusion": "SUCCESS",
2009      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2010      "name": "check (ubuntu-latest)",
2011      "status": "COMPLETED",
2012      "workflowName": "CI"
2013    },
2014    {
2015      "__typename": "CheckRun",
2016      "conclusion": "SUCCESS",
2017      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2018      "name": "rustfmt",
2019      "status": "COMPLETED",
2020      "workflowName": "CI"
2021    },
2022    {
2023      "__typename": "StatusContext",
2024      "context": "CodeRabbit",
2025      "state": "SUCCESS",
2026      "targetUrl": ""
2027    }
2028  ],
2029  "reviews": [],
2030  "comments": [
2031    {
2032      "author": {
2033        "login": "coderabbitai"
2034      },
2035      "authorAssociation": "NONE",
2036      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro Plus\n> \n> **Run ID**: `78e70bf3-c5a0-4269-a96c-2afb2dba7eff`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=10)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%2"
2037    }
2038  ]
2039}"####;
2040
2041    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
2042    const RED_OPEN: &str = r####"{
2043  "url": "https://github.com/yukimemi/magi/pull/9",
2044  "number": 9,
2045  "state": "OPEN",
2046  "mergeStateStatus": "UNSTABLE",
2047  "statusCheckRollup": [
2048    {
2049      "__typename": "CheckRun",
2050      "conclusion": "SUCCESS",
2051      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2052      "name": "check (ubuntu-latest)",
2053      "status": "COMPLETED",
2054      "workflowName": "CI"
2055    },
2056    {
2057      "__typename": "CheckRun",
2058      "conclusion": "SUCCESS",
2059      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2060      "name": "rustfmt",
2061      "status": "COMPLETED",
2062      "workflowName": "CI"
2063    },
2064    {
2065      "__typename": "CheckRun",
2066      "conclusion": "FAILURE",
2067      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2068      "name": "editorconfig",
2069      "status": "COMPLETED",
2070      "workflowName": "CI"
2071    },
2072    {
2073      "__typename": "StatusContext",
2074      "context": "CodeRabbit",
2075      "state": "SUCCESS",
2076      "targetUrl": ""
2077    }
2078  ],
2079  "reviews": [],
2080  "comments": [
2081    {
2082      "author": {
2083        "login": "coderabbitai"
2084      },
2085      "authorAssociation": "NONE",
2086      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `91e0dc24-6040-4c3d-92c6-f7d2b542523d`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderab"
2087    }
2088  ]
2089}"####;
2090
2091    /// Pull request #9's real payload with its `editorconfig` check rewound to the `IN_PROGRESS` / `conclusion: null` pair `gh` reports while a job is still in flight.
2092    const PENDING_OPEN: &str = r####"{
2093  "url": "https://github.com/yukimemi/magi/pull/9",
2094  "number": 9,
2095  "state": "OPEN",
2096  "statusCheckRollup": [
2097    {
2098      "__typename": "CheckRun",
2099      "conclusion": "SUCCESS",
2100      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2101      "name": "check (ubuntu-latest)",
2102      "status": "COMPLETED",
2103      "workflowName": "CI"
2104    },
2105    {
2106      "__typename": "CheckRun",
2107      "conclusion": "SUCCESS",
2108      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2109      "name": "rustfmt",
2110      "status": "COMPLETED",
2111      "workflowName": "CI"
2112    },
2113    {
2114      "__typename": "CheckRun",
2115      "conclusion": null,
2116      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2117      "name": "editorconfig",
2118      "status": "IN_PROGRESS",
2119      "workflowName": "CI"
2120    },
2121    {
2122      "__typename": "StatusContext",
2123      "context": "CodeRabbit",
2124      "state": "SUCCESS",
2125      "targetUrl": ""
2126    }
2127  ],
2128  "reviews": [],
2129  "comments": []
2130}"####;
2131
2132    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
2133    const MERGED: &str = r####"{
2134  "url": "https://github.com/yukimemi/magi/pull/16",
2135  "number": 16,
2136  "state": "MERGED",
2137  "statusCheckRollup": [
2138    {
2139      "__typename": "CheckRun",
2140      "conclusion": "SUCCESS",
2141      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2142      "name": "check (ubuntu-latest)",
2143      "status": "COMPLETED",
2144      "workflowName": "CI"
2145    },
2146    {
2147      "__typename": "CheckRun",
2148      "conclusion": "SUCCESS",
2149      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2150      "name": "review",
2151      "status": "COMPLETED",
2152      "workflowName": "claude-review"
2153    }
2154  ],
2155  "reviews": [],
2156  "comments": []
2157}"####;
2158
2159    /// Pull request #12's real payload - a green pull request carrying CodeRabbit's walkthrough and a Claude review that found a real bug - rewound to the `OPEN` state it was in when that review was posted.
2160    const REVIEWED_OPEN: &str = r####"{
2161  "url": "https://github.com/yukimemi/magi/pull/12",
2162  "number": 12,
2163  "state": "OPEN",
2164  "statusCheckRollup": [
2165    {
2166      "__typename": "CheckRun",
2167      "conclusion": "SUCCESS",
2168      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2169      "name": "check (ubuntu-latest)",
2170      "status": "COMPLETED",
2171      "workflowName": "CI"
2172    },
2173    {
2174      "__typename": "CheckRun",
2175      "conclusion": "SUCCESS",
2176      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2177      "name": "review",
2178      "status": "COMPLETED",
2179      "workflowName": "claude-review"
2180    }
2181  ],
2182  "reviews": [
2183    {
2184      "author": {
2185        "login": "claude"
2186      },
2187      "state": "COMMENTED",
2188      "body": ""
2189    }
2190  ],
2191  "comments": [
2192    {
2193      "author": {
2194        "login": "coderabbitai"
2195      },
2196      "authorAssociation": "NONE",
2197      "body": "<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- This is an auto-generated comment: skip review by coderabbit.ai -->\n\n> [!IMPORTANT]\n> - [ ] <!-- {\"checkboxId\":\"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe\"} --> 🔍 Trigger review\n> \n> This repository does not receive automatic reviews because it has fewer than 10 stars.\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Team\n> \n> **Run ID**: `72058bf3-b7df-41d9-8e4d-a06a31be4a26`\n> \n> </details>\n\n<!-- end of auto-generated comment: skip review by coderabbit.ai -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=12)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summa"
2198    },
2199    {
2200      "author": {
2201        "login": "claude"
2202      },
2203      "authorAssociation": "NONE",
2204      "body": "**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)\n\n---\n### Review: `magi review <branch>` — cheap-half-only graph\n\nRead through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.\n\n**Correctness**\n\n- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `\"(existing branch)\"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"
2205    }
2206  ]
2207}"####;
2208
2209    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
2210    const INLINE: &str = r####"[
2211  {
2212    "user": {
2213      "login": "claude[bot]"
2214    },
2215    "path": "src/graph.rs",
2216    "line": 231,
2217    "body": "Minor edge case: unlike `implement()` (which sets `c.empty = commits == 0 || patch.trim().is_empty()`, `src/graph.rs:472`), the seeded review-only candidate always sets `empty: false` once `commits > 0` is confirmed, without checking whether the diff itself is actually empty (e.g. a commit immediately followed by a revert nets zero file changes). Such a branch would pass `Runner::review`'s validation and proceed into a review round with an empty patch, where `implement()`'s equivalent path would"
2218  }
2219]"####;
2220
2221    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
2222    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
2223<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
2224
2225> [!IMPORTANT]
2226> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
2227> 
2228> This repository does not receive automatic reviews because it has fewer than 10 stars.
2229> 
2230> <details>
2231> <summary>⚙️ Run configuration</summary>
2232> 
2233> **Configuration used**: defaults
2234> 
2235> **Review profile**: CHILL
2236> 
2237> **Plan**: Team
2238> 
2239> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
2240> 
2241> </details>
2242
2243<!-- end of auto-generated comment: skip review by coderabbit.ai -->
2244
2245<!-- tips_start -->
2246
2247---
2248
2249Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yukimemi/magi&utm_content=16)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
2250
2251<details>
2252<summary>❤️ Share</summary>
2253
2254- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20off"####;
2255
2256    /// The Claude review job's real comment while it is still working: a heading and a task list, and nothing that asks for a change.
2257    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
2258
2259---
2260### Reviewing PR #16
2261
2262- [x] Read AGENTS.md conventions
2263- [x] Review `src/daemon.rs` changes
2264- [x] Review `src/main.rs` changes (new `doctor` reporting)
2265- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
2266- [x] Check test coverage for new behavior
2267- [x] Run verification commands (blocked — see note)
2268- [x] Post findings"####;
2269
2270    /// The same job's real comment on pull request #12 once it had something to say.
2271    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
2272
2273---
2274### Review: `magi review <branch>` — cheap-half-only graph
2275
2276Read through `src/graph.rs`, `src/main.rs`, `src/prompt.rs`, and the new/edited tests, and traced the claimed degeneration (`prep` short-circuits on a non-empty candidate list, `implement` skips because `commits != 0`, `judge`/`vote` skip on `viable().len() == 1`, `tally` skips because it's pre-set, `fold_losers` has no losers) against the actual code — it holds up. CI (`cargo make check`) is green on this PR.
2277
2278**Correctness**
2279
2280- One real bug, flagged inline on `src/graph.rs:1255`: the fixer-agent fallback (`self.roles.implementers[winner.index].clone()`) is unreachable in the normal graph (a real candidate's `winner.agent` always resolves via `config.agent(...)`), but a review-only run's `winner.agent` is always the `"(existing branch)"` sentinel, so this fallback now runs on *every* review-only fix that has no dedicated `[roles] fixer`. `graph.candidates` has no lower-bound validation, so a `magi.toml` tuned for review-only use (`candidates = 0`, plausible given this PR's own cost rationale) would panic with an out-of-bounds index the first time a"####;
2281
2282    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
2283        PrState {
2284            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
2285            number: 16,
2286            state: PrLifecycle::Open,
2287            checks,
2288            // These tests are about red-means-fix, so a red here is one the
2289            // forge gates on. Without saying so they would assert the new
2290            // "merge past a check nobody requires" path by accident.
2291            blocking: if matches!(checks, Checks::Red) {
2292                Blocking::Yes
2293            } else {
2294                Blocking::No
2295            },
2296            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
2297            review_comments: (0..comments)
2298                .map(|i| ReviewComment {
2299                    author: "coderabbitai".to_owned(),
2300                    path: Some("src/graph.rs".to_owned()),
2301                    line: Some(231),
2302                    body: format!("finding {i}"),
2303                })
2304                .collect(),
2305        }
2306    }
2307
2308    #[test]
2309    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
2310        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
2311        assert_eq!(state.number, 10);
2312        assert_eq!(state.state, PrLifecycle::Open);
2313        assert_eq!(state.checks, Checks::Green);
2314        assert!(state.failing.is_empty());
2315        assert!(
2316            state.review_comments.is_empty(),
2317            "the only comment is CodeRabbit's trigger notice: {:?}",
2318            state.review_comments
2319        );
2320        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
2321    }
2322
2323    #[test]
2324    fn a_failing_check_parses_as_red_and_is_named() {
2325        let state = parse_pr(RED_OPEN).expect("red fixture parses");
2326        assert_eq!(state.checks, Checks::Red);
2327        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
2328        // The captured payload says `UNSTABLE` - mergeable, with a check
2329        // nobody requires red - which is exactly the shape that had to be
2330        // merged by hand. Asserted separately, in
2331        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
2332        // test is about is that a red check is *named*, so the reason a fixer
2333        // is handed says which one; so it asks the blocking question here.
2334        let mut blocking = state.clone();
2335        blocking.blocking = Blocking::Yes;
2336        match decide(&blocking, 0, 4, Duration::ZERO) {
2337            Step::Fix { reason } => {
2338                assert!(reason.contains("editorconfig"), "reason: {reason}");
2339                assert!(reason.contains("failing"), "reason: {reason}");
2340            }
2341            other => panic!("expected a fix round, got {other:?}"),
2342        }
2343    }
2344
2345    #[test]
2346    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
2347        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
2348        assert_eq!(state.checks, Checks::Pending);
2349        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
2350    }
2351
2352    #[test]
2353    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
2354        let state = parse_pr(MERGED).expect("merged fixture parses");
2355        assert_eq!(state.state, PrLifecycle::Merged);
2356        assert_eq!(
2357            decide(&state, 0, 4, Duration::ZERO),
2358            Step::Done { merged: true }
2359        );
2360    }
2361
2362    #[test]
2363    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
2364        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
2365        assert_eq!(state.checks, Checks::Green);
2366        let authors: Vec<&str> = state
2367            .review_comments
2368            .iter()
2369            .map(|c| c.author.as_str())
2370            .collect();
2371        assert_eq!(
2372            authors,
2373            vec!["claude"],
2374            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
2375        );
2376        match decide(&state, 0, 4, Duration::ZERO) {
2377            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
2378            other => panic!("expected a fix round, got {other:?}"),
2379        }
2380    }
2381
2382    #[test]
2383    fn inline_review_comments_keep_their_file_and_line() {
2384        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
2385        assert_eq!(comments.len(), 1);
2386        assert_eq!(comments[0].author, "claude[bot]");
2387        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
2388        assert_eq!(comments[0].line, Some(231));
2389        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
2390    }
2391
2392    #[test]
2393    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
2394        assert!(
2395            is_noise(CODERABBIT_TRIGGER),
2396            "CodeRabbit's trigger notice declares itself not a review"
2397        );
2398        assert!(
2399            is_noise(CLAUDE_CHECKLIST),
2400            "a progress checklist asks for nothing"
2401        );
2402        assert!(
2403            !is_noise(CLAUDE_FINDING),
2404            "a review that names a bug is input, not noise"
2405        );
2406
2407        let mut clean = pr(Checks::Green, &[], 0);
2408        clean.review_comments.push(ReviewComment {
2409            author: "coderabbitai".to_owned(),
2410            path: None,
2411            line: None,
2412            body: CODERABBIT_TRIGGER.to_owned(),
2413        });
2414        clean.review_comments.retain(|c| !is_noise(&c.body));
2415        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
2416
2417        let mut found = pr(Checks::Green, &[], 0);
2418        found.review_comments.push(ReviewComment {
2419            author: "claude".to_owned(),
2420            path: None,
2421            line: None,
2422            body: CLAUDE_FINDING.to_owned(),
2423        });
2424        found.review_comments.retain(|c| !is_noise(&c.body));
2425        assert!(matches!(
2426            decide(&found, 0, 4, Duration::ZERO),
2427            Step::Fix { .. }
2428        ));
2429    }
2430
2431    #[test]
2432    fn the_policy_table_holds_for_every_combination_that_matters() {
2433        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
2434            (
2435                "pending checks are waited for, even on the last round",
2436                pr(Checks::Pending, &[], 0),
2437                4,
2438                4,
2439                Duration::ZERO,
2440                Step::Wait,
2441            ),
2442            (
2443                "red checks are fixed",
2444                pr(Checks::Red, &["editorconfig"], 0),
2445                0,
2446                4,
2447                Duration::ZERO,
2448                Step::Fix {
2449                    reason: "1 check(s) failing: editorconfig".to_owned(),
2450                },
2451            ),
2452            (
2453                "green with comments is fixed, not merged",
2454                pr(Checks::Green, &[], 2),
2455                1,
2456                4,
2457                Duration::ZERO,
2458                Step::Fix {
2459                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
2460                        .to_owned(),
2461                },
2462            ),
2463            (
2464                "green and clean merges",
2465                pr(Checks::Green, &[], 0),
2466                3,
2467                4,
2468                Duration::ZERO,
2469                Step::Merge,
2470            ),
2471            (
2472                "an unreadable rollup is waited on while the grace lasts",
2473                pr(Checks::Unknown, &[], 0),
2474                0,
2475                4,
2476                Duration::ZERO,
2477                Step::Wait,
2478            ),
2479            (
2480                "an unreadable rollup is never merged once the grace is spent",
2481                pr(Checks::Unknown, &[], 0),
2482                0,
2483                4,
2484                CHECKS_GRACE,
2485                Step::GiveUp {
2486                    reason: "no check status is readable on the pull request after 3 minute(s); \
2487                             refusing to merge on a guess"
2488                        .to_owned(),
2489                },
2490            ),
2491        ];
2492        for (what, state, round, budget, waited, want) in cases {
2493            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
2494        }
2495    }
2496
2497    #[test]
2498    fn the_forge_verdict_survives_the_round_trip_from_gh() {
2499        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
2500        // requested but never parsed is the kind of thing that looks wired up
2501        // and answers `Unsaid` forever.
2502        let green = parse_pr(GREEN_OPEN).expect("parse");
2503        assert_eq!(green.blocking, Blocking::No);
2504        let red = parse_pr(RED_OPEN).expect("parse");
2505        assert_eq!(
2506            red.blocking,
2507            Blocking::No,
2508            "`UNSTABLE` is mergeable: the red check is one nobody requires"
2509        );
2510        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
2511        // A payload from an older `gh` has no such field at all.
2512        let quiet =
2513            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
2514        assert_eq!(quiet.blocking, Blocking::Unsaid);
2515    }
2516
2517    #[test]
2518    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
2519        // Pull request 37's only red check was `editorconfig`, failing
2520        // because the action could not fetch its own binary after
2521        // editorconfig-checker v4 renamed its release assets. The repository
2522        // does not require it. magi answered by asking a fixer to repair a
2523        // change that was fine, and the pull request had to be merged by hand.
2524        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
2525        nonblocking.blocking = Blocking::No;
2526        assert_eq!(
2527            decide(&nonblocking, 0, 4, Duration::ZERO),
2528            Step::Merge,
2529            "the forge says nothing is in the way, so nothing is"
2530        );
2531
2532        // The same red, gated on: that is a fix round, as before.
2533        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
2534        blocking.blocking = Blocking::Yes;
2535        assert!(matches!(
2536            decide(&blocking, 0, 4, Duration::ZERO),
2537            Step::Fix { .. }
2538        ));
2539
2540        // A review comment still outranks green-enough: a non-required red
2541        // must not become a way to merge past an unanswered reviewer.
2542        let mut commented = pr(Checks::Red, &["coverage"], 1);
2543        commented.blocking = Blocking::No;
2544        assert!(matches!(
2545            decide(&commented, 0, 4, Duration::ZERO),
2546            Step::Fix { .. }
2547        ));
2548
2549        // And silence from the forge is not consent.
2550        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
2551        unsaid.blocking = Blocking::Unsaid;
2552        assert!(matches!(
2553            decide(&unsaid, 0, 4, Duration::ZERO),
2554            Step::Fix { .. }
2555        ));
2556    }
2557
2558    #[test]
2559    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
2560        // Pull requests 35 and 37 were both rebased by hand: a competition
2561        // that runs for two hours against a repository merging pull requests
2562        // all day conflicts on the way in, and that is arithmetic rather
2563        // than a defect in the change.
2564        let mut conflicted = pr(Checks::Green, &[], 0);
2565        conflicted.blocking = Blocking::Conflict;
2566        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
2567
2568        // Decided before the checks, and even with the rounds spent: every
2569        // check on a branch that cannot land is an answer about a state that
2570        // cannot land, and a conflict is not the change's fault.
2571        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
2572        red.blocking = Blocking::Conflict;
2573        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
2574
2575        // The lifecycle still wins over everything, conflict included.
2576        let mut merged = pr(Checks::Red, &[], 0);
2577        merged.blocking = Blocking::Conflict;
2578        merged.state = PrLifecycle::Merged;
2579        assert_eq!(
2580            decide(&merged, 0, 4, Duration::ZERO),
2581            Step::Done { merged: true }
2582        );
2583    }
2584
2585    #[test]
2586    fn the_forge_verdict_is_read_off_merge_state_status() {
2587        // The spellings that mean "mergeable". `UNSTABLE` is the one that
2588        // matters: mergeable, with a non-required check red or still running.
2589        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
2590            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
2591            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
2592        }
2593        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
2594        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
2595        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
2596        // An older `gh`, or a token without the scope, says nothing - and
2597        // refusing to guess is the rule everywhere else in this module.
2598        for quiet in ["", "UNKNOWN"] {
2599            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
2600            assert!(Blocking::of(quiet).stops_a_merge());
2601        }
2602    }
2603
2604    #[test]
2605    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
2606        let argv = merge_argv(28, "Merge magi run ec12 (candidate B)");
2607        // The exact stderr from run ec12, in a jj-colocated repository.
2608        let jj = "could not determine current branch: failed to run git: not on any branch";
2609
2610        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
2611            .expect("the forge says merged, so it merged");
2612        assert!(landed.ok);
2613        assert!(
2614            landed.detail.contains("but the pull request is merged"),
2615            "the record must not read as a clean success: {}",
2616            landed.detail
2617        );
2618        assert!(
2619            landed.detail.contains("not on any branch"),
2620            "and it must keep what the command actually said: {}",
2621            landed.detail
2622        );
2623
2624        // A pull request still open means the merge really failed.
2625        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
2626        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
2627        // And an unreadable answer is not evidence of success.
2628        assert!(merged_after_all(&argv, jj, None).is_none());
2629    }
2630
2631    #[test]
2632    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
2633        let mut state = pr(Checks::Red, &["editorconfig"], 3);
2634        state.state = PrLifecycle::Closed;
2635        assert_eq!(
2636            decide(&state, 0, 4, Duration::ZERO),
2637            Step::Done { merged: false },
2638            "a human closing the pull request ends the loop, whatever CI says"
2639        );
2640    }
2641
2642    #[test]
2643    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
2644        let red = decide(
2645            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
2646            4,
2647            4,
2648            Duration::ZERO,
2649        );
2650        match red {
2651            Step::GiveUp { reason } => {
2652                assert!(reason.contains("editorconfig"), "reason: {reason}");
2653                assert!(reason.contains("test (macos)"), "reason: {reason}");
2654                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
2655            }
2656            other => panic!("expected a give-up, got {other:?}"),
2657        }
2658
2659        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
2660        match commented {
2661            Step::GiveUp { reason } => {
2662                assert!(reason.contains("unresolved"), "reason: {reason}");
2663                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
2664            }
2665            other => panic!("expected a give-up, got {other:?}"),
2666        }
2667    }
2668
2669    #[test]
2670    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
2671        let candidate_commit = "magi: candidate A (uncommitted work)";
2672        let subject = merge_subject(candidate_commit, "add retries to the uploader");
2673        let argv = merge_argv(16, &subject);
2674
2675        assert!(argv.contains(&"--squash".to_owned()));
2676        assert!(argv.contains(&"--delete-branch".to_owned()));
2677        assert!(argv.contains(&"--subject".to_owned()));
2678        assert_eq!(
2679            argv.last().map(String::as_str),
2680            Some("add retries to the uploader"),
2681            "the subject must not be the candidate commit message"
2682        );
2683        assert_ne!(subject, candidate_commit);
2684    }
2685
2686    #[test]
2687    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
2688        assert_eq!(
2689            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
2690            "feat: a queue, an unattended loop, and a phone UI"
2691        );
2692        assert_eq!(
2693            merge_subject("", "# port the retry logic\n\ndetails"),
2694            "port the retry logic",
2695            "an empty title falls back to the task's first line, heading marks stripped"
2696        );
2697    }
2698
2699    #[test]
2700    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
2701        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
2702        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
2703        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
2704        assert_eq!(job_of("https://coderabbit.ai/status"), None);
2705        assert_eq!(run_of(""), None);
2706    }
2707
2708    #[test]
2709    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
2710        let mut out = Vec::new();
2711        push_if_outstanding(
2712            &mut out,
2713            ReviewComment {
2714                author: "yukimemi".to_owned(),
2715                path: None,
2716                line: None,
2717                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
2718            },
2719        );
2720        assert!(out.is_empty());
2721    }
2722
2723    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
2724    /// falls back to the repository - which keeps these tests free of a
2725    /// worktree, a `git` invocation and a network.
2726    fn run_state() -> RunState {
2727        RunState::new(
2728            std::path::PathBuf::from("/repo/magi"),
2729            "main".to_owned(),
2730            "abcdef1234".to_owned(),
2731            "add retries to the uploader".to_owned(),
2732            crate::config::Config::default(),
2733        )
2734    }
2735
2736    fn green_pr() -> PrState {
2737        PrState {
2738            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
2739            number: 42,
2740            state: PrLifecycle::Open,
2741            checks: Checks::Green,
2742            // The forge sees nothing in the way unless a test says otherwise.
2743            blocking: Blocking::No,
2744            failing: Vec::new(),
2745            review_comments: vec![ReviewComment {
2746                author: "coderabbitai".to_owned(),
2747                path: Some("src/land.rs".to_owned()),
2748                line: Some(212),
2749                body: "this branch never checks the exit code".to_owned(),
2750            }],
2751        }
2752    }
2753
2754    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
2755
2756    fn panel() -> String {
2757        approval_panel(
2758            &run_state(),
2759            &green_pr(),
2760            NUMSTAT,
2761            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
2762            &[
2763                "land: ask before merging".to_owned(),
2764                "land: colour the diff".to_owned(),
2765            ],
2766            "feat: merge approval from the phone",
2767        )
2768    }
2769
2770    #[test]
2771    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
2772        let html = panel();
2773        for needle in [
2774            "42",
2775            "main",
2776            "src/land.rs",
2777            "src/web.rs",
2778            "assets/logo.png",
2779            "feat: merge approval from the phone",
2780            "land: ask before merging",
2781            "land: colour the diff",
2782            "coderabbitai",
2783            "this branch never checks the exit code",
2784            "green",
2785        ] {
2786            assert!(html.contains(needle), "the panel must state `{needle}`");
2787        }
2788    }
2789
2790    #[test]
2791    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
2792        let html = panel();
2793        assert!(!html.contains("<script"), "no script survives the csp");
2794        assert!(!html.contains("<form"), "form-action is 'none'");
2795        let pr = green_pr();
2796        assert_eq!(
2797            html.matches("http").count(),
2798            html.matches(pr.url.as_str()).count(),
2799            "the only http url in the panel is the pull request's own link"
2800        );
2801    }
2802
2803    #[test]
2804    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
2805        let html = panel();
2806        assert!(
2807            html.contains(">+</span>"),
2808            "an added line carries a `+` in the gutter, not only a background"
2809        );
2810        assert!(
2811            html.contains(">-</span>"),
2812            "a removed line carries a `-` in the gutter, not only a background"
2813        );
2814        assert!(
2815            html.contains(">new line</span>"),
2816            "the marker is moved to the gutter, so the body is printed once without it"
2817        );
2818    }
2819
2820    #[test]
2821    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
2822        let total = DIFF_MAX_LINES + 100;
2823        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
2824        let html = approval_panel(
2825            &run_state(),
2826            &green_pr(),
2827            NUMSTAT,
2828            &diff,
2829            &[],
2830            "feat: something long",
2831        );
2832        assert!(
2833            html.contains(&format!("100 of {total} diff lines omitted")),
2834            "the note must say exactly how much was cut"
2835        );
2836        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
2837        assert!(
2838            !html.contains(&format!("line {DIFF_MAX_LINES}")),
2839            "nothing past the threshold is rendered"
2840        );
2841        assert!(
2842            html.contains("/repo/magi"),
2843            "the note says where the rest is"
2844        );
2845    }
2846
2847    #[test]
2848    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
2849        let html = approval_panel(
2850            &run_state(),
2851            &green_pr(),
2852            "1\t2\tsrc/<b>&\"x\"'.rs",
2853            "",
2854            &[],
2855            "subject",
2856        );
2857        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
2858        assert!(
2859            !html.contains("<b>"),
2860            "an agent-influenced path must never become markup"
2861        );
2862    }
2863
2864    #[test]
2865    fn only_the_merge_choice_merges_and_silence_holds() {
2866        let table = [
2867            (None, Approval::Hold),
2868            (Some("merge"), Approval::Merge),
2869            (Some(" merge\n"), Approval::Merge),
2870            (Some("hold"), Approval::Hold),
2871            (Some(""), Approval::Hold),
2872            (Some("yes"), Approval::Hold),
2873        ];
2874        for (answer, want) in table {
2875            assert_eq!(
2876                approval(answer),
2877                want,
2878                "answer {answer:?} must resolve to {want:?}"
2879            );
2880        }
2881    }
2882
2883    #[test]
2884    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
2885        let rows = parse_numstat(NUMSTAT);
2886        assert_eq!(
2887            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
2888            ["src/web.rs", "src/land.rs", "assets/logo.png"]
2889        );
2890        assert_eq!(rows[2].added, None, "a binary file has no line counts");
2891    }
2892    #[test]
2893    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
2894        // Reported from a real run: the merge question arrived in English on a
2895        // repository with `language = "ja"`. magi's own strings have to follow
2896        // that setting too - "it is a literal in Rust" is not an answer.
2897        let mut state = run_state();
2898        state.config.graph.language = "ja".to_owned();
2899        let pr = green_pr();
2900        let commits = ["c1".to_owned()];
2901
2902        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
2903        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
2904        assert!(ja.contains("squash されるコミット"), "{ja}");
2905        assert!(ja.contains("レビューコメント"), "{ja}");
2906        assert!(ja.contains("差分"), "{ja}");
2907        assert!(
2908            !ja.contains("Commits being squashed"),
2909            "no English left over"
2910        );
2911
2912        let w = words("ja");
2913        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
2914        assert!(
2915            w.approval_detail("http://x/1", "main", "feat: x")
2916                .contains("パネル")
2917        );
2918
2919        // The evidence itself is language-neutral and must survive either way.
2920        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
2921        assert!(ja.contains("feat: x"), "nor is the merge subject");
2922
2923        // English stays the default, and a language magi cannot check falls
2924        // back to it rather than shipping a guess.
2925        state.config.graph.language = "en".to_owned();
2926        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
2927        assert!(en.contains("Commits being squashed"), "{en}");
2928        assert_eq!(words("Klingon").html_lang, "en");
2929    }
2930}