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