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::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::{AgentSpec, MergeMode};
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{MergeOutcome, RunState, RunStatus, tail};
58
59/// How often the pull request is re-read while its checks are still running.
60///
61/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
62/// entirely on `gh` invocations, and anything much longer adds latency to every
63/// single round of a loop that already waits for agents.
64pub const POLL: Duration = Duration::from_secs(30);
65
66/// How long one wait may last before landing gives up on the checks finishing.
67///
68/// A workflow that has not settled in forty-five minutes is stuck on a runner
69/// queue, a missing approval, or a hung job - none of which more polling fixes,
70/// and all of which a person needs to see.
71pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
72
73/// How long the checks may stay unreadable before landing gives up on them.
74///
75/// GitHub registers a workflow run some seconds after the branch is pushed, so
76/// immediately after a pull request is opened "no checks" and "no CI in this
77/// repository" look identical. Measured on run 01c2: magi opened pull request
78/// 22, read `unknown` four seconds later, refused to merge on a guess and
79/// marked the run blocked - and every check on that pull request was green
80/// minutes afterwards, with the whole competition then re-run from scratch for
81/// a task that was already finished. Three minutes is well past the observed
82/// registration delay and still bounded, so a repository that genuinely has no
83/// checks costs one three-minute wait and then says so.
84pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
85
86/// Bytes of failing log kept per check. The fixer needs the assertion and the
87/// frame around it, not the forty thousand lines of `cargo` output above it.
88const LOG_TAIL: usize = 4_000;
89
90/// Failing checks whose logs are fetched. Beyond a handful the failures share a
91/// cause, and fetching each one costs a `gh` round trip.
92const MAX_LOGS: usize = 3;
93
94/// Marker carried by every comment magi posts on a pull request.
95///
96/// Without it magi's own "still failing" comment is indistinguishable from a
97/// reviewer's, and the next observation would hand magi's own prose to the
98/// fixer as a finding.
99pub const MARKER: &str = "<!-- magi:land -->";
100
101/// Markers a bot puts in a comment to say that the comment is not a review.
102///
103/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
104/// the walkthrough summary, the "thanks for using" footer - and its actual
105/// findings arrive as *inline* review comments with a path and a line. Taking
106/// the bot at its word is more honest than guessing from prose, and it is the
107/// difference between a fix round that has something to fix and one that asks
108/// an agent to act on a quota notice.
109const NOT_A_REVIEW: [&str; 3] = [
110    "skip review by coderabbit.ai",
111    "summarize by coderabbit.ai",
112    "<!-- tips_start -->",
113];
114
115/// Where a pull request is in its life.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum PrLifecycle {
118    /// Still ours to land.
119    Open,
120    /// Already merged, by us or by a person.
121    Merged,
122    /// Closed without merging.
123    Closed,
124}
125
126/// The aggregate verdict of a pull request's checks.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum Checks {
129    /// At least one check has not finished.
130    Pending,
131    /// Every check passed (a skipped check counts as passed: the review
132    /// workflow skips release and bot pull requests by design).
133    Green,
134    /// At least one check finished without passing.
135    Red,
136    /// Nothing readable - no rollup at all, or a status magi does not know.
137    Unknown,
138}
139
140impl PrLifecycle {
141    /// Stable lower-case name, as the API and the reports spell it.
142    pub fn as_str(self) -> &'static str {
143        match self {
144            Self::Open => "open",
145            Self::Merged => "merged",
146            Self::Closed => "closed",
147        }
148    }
149}
150
151impl Checks {
152    /// Stable lower-case name, as the API and the reports spell it.
153    pub fn as_str(self) -> &'static str {
154        match self {
155            Self::Pending => "pending",
156            Self::Green => "green",
157            Self::Red => "red",
158            Self::Unknown => "unknown",
159        }
160    }
161}
162
163/// One outstanding review comment, human or bot.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct ReviewComment {
166    /// Login of whoever wrote it.
167    pub author: String,
168    /// File it was left on, for inline review comments.
169    pub path: Option<String>,
170    /// Line it was left on, when the comment is inline and still anchored.
171    pub line: Option<u64>,
172    /// The comment itself, as written.
173    pub body: String,
174}
175
176/// One observation of a pull request.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct PrState {
179    /// Pull request url, as `gh` reports it.
180    pub url: String,
181    /// Pull request number.
182    pub number: u64,
183    /// Open, merged, or closed.
184    pub state: PrLifecycle,
185    /// Aggregate check verdict.
186    pub checks: Checks,
187    /// Names of the checks that finished without passing.
188    pub failing: Vec<String>,
189    /// Comments that still want an answer, human and bot.
190    pub review_comments: Vec<ReviewComment>,
191    /// Whether the forge itself considers the failures blocking.
192    pub blocking: Blocking,
193}
194
195/// Whether a failing check actually stands between the pull request and
196/// `main`, according to the forge.
197///
198/// The rollup lists every check equally, so `coverage` going red on a
199/// repository that deliberately does not require it looked exactly like a
200/// broken build - and magi answered by spending a fix round on a change that
201/// was fine. Pull request 37 had to be merged by hand for that reason: the
202/// only red check was `editorconfig`, which was failing because the *action*
203/// could not fetch its own binary, and which the repository does not require.
204///
205/// `mergeStateStatus` is where GitHub applies the required-check set, so it
206/// is the one field that can tell the difference.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum Blocking {
209    /// Required checks are satisfied and the branch merges cleanly.
210    No,
211    /// Something required is failing or missing.
212    Yes,
213    /// The branch no longer merges: the base moved under it.
214    Conflict,
215    /// The forge did not say - an older `gh`, or a token without the scope.
216    /// Treated as `Yes`, because refusing to guess is the rule everywhere
217    /// else in this module.
218    Unsaid,
219}
220
221impl Blocking {
222    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
223    fn of(raw: &str) -> Self {
224        match raw.to_ascii_uppercase().as_str() {
225            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
226            // non-required check failing or still running.
227            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
228            "DIRTY" => Self::Conflict,
229            "" | "UNKNOWN" => Self::Unsaid,
230            // BLOCKED, BEHIND, DRAFT: something has to change first.
231            _ => Self::Yes,
232        }
233    }
234
235    /// Does this stand between the pull request and the base branch?
236    #[must_use]
237    pub fn stops_a_merge(self) -> bool {
238        !matches!(self, Self::No)
239    }
240}
241
242/// What the loop decided to do next. Pure, so the policy is testable.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum Step {
245    /// Checks are still running; re-read the pull request after [`POLL`].
246    Wait,
247    /// The base moved and the branch no longer merges: rebase it.
248    ///
249    /// Not a fix round. Nothing is wrong with the change - a competition
250    /// that runs for two hours against a repository merging pull requests
251    /// all day conflicts on the way in, and that is arithmetic rather than a
252    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
253    /// this.
254    Rebase,
255    /// Red checks or unresolved comments; run a fix round.
256    Fix {
257        /// What is unhappy, in one line, for the run log and the fix prompt.
258        reason: String,
259    },
260    /// Green and nothing outstanding; merge it.
261    Merge,
262    /// The pull request left our hands.
263    Done {
264        /// Did it land, or was it closed?
265        merged: bool,
266    },
267    /// Stop and leave the pull request to a person.
268    GiveUp {
269        /// Why magi stopped, in one line.
270        reason: String,
271    },
272}
273
274/// The outcome to record when `gh pr merge` exits non-zero, given what the
275/// pull request looked like immediately afterwards.
276///
277/// `gh pr merge` merges server-side first and only then does local work -
278/// deleting the branch, switching back to a base branch - so a non-zero exit
279/// does not mean the merge did not happen. In a jj-colocated repository it
280/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
281/// ends with "could not determine current branch: not on any branch" *after*
282/// the merge has landed. Run ec12 merged pull request 28 into `main` and
283/// recorded `ok: false`, and its task was held waiting for a merge that was
284/// already done.
285///
286/// So the forge is asked, and its answer wins - the same authority [`decide`]
287/// gives the pull request's own state over everything else. The recorded
288/// detail carries both facts, because "the command failed and the merge
289/// happened anyway" is exactly what someone reading the run later needs to
290/// know.
291///
292/// `None` means the merge really did not happen, including when the pull
293/// request could not be read at all: an unreadable answer is not evidence of
294/// success.
295pub(crate) fn merged_after_all(
296    argv: &[String],
297    stderr: &str,
298    after: Option<PrLifecycle>,
299) -> Option<MergeOutcome> {
300    if after? != PrLifecycle::Merged {
301        return None;
302    }
303    Some(MergeOutcome {
304        mode: MergeMode::Pr,
305        ok: true,
306        detail: format!(
307            "gh {} (the command reported `{}`, but the pull request is merged)",
308            argv.join(" "),
309            stderr.trim()
310        ),
311    })
312}
313
314/// Decide the next step. No I/O.
315///
316/// `round` counts the fix rounds already spent, so `round == budget` means the
317/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
318/// must not consume the allowance meant for actual fixes.
319///
320/// The order of the tests is the policy:
321///
322/// 1. **The pull request's own state wins.** A merge or a close that happened
323///    underneath us is the end of the story regardless of what the checks say.
324/// 2. **Pending beats red.** A check that is still running may yet fail, and one
325///    fix round that addresses every failure is cheaper than two that each
326///    address half - the fix pushes and restarts the whole suite anyway.
327/// 3. **Comments outrank green.** An unresolved comment holds the merge even
328///    when CI is happy; that is what a review is for.
329/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
330///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
331///    not been given its workflow runs yet. Past the grace they are treated as
332///    genuinely missing and magi stops rather than merge on a guess.
333pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
334    match pr.state {
335        PrLifecycle::Merged => return Step::Done { merged: true },
336        PrLifecycle::Closed => return Step::Done { merged: false },
337        PrLifecycle::Open => {}
338    }
339
340    // Before the checks: every check on a branch that cannot land is an
341    // answer about a state that cannot land.
342    if pr.blocking == Blocking::Conflict {
343        return Step::Rebase;
344    }
345
346    let spent = round >= budget;
347    match pr.checks {
348        Checks::Pending => Step::Wait,
349        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
350        Checks::Unknown => Step::GiveUp {
351            reason: format!(
352                "no check status is readable on the pull request after {} minute(s); \
353                 refusing to merge on a guess",
354                CHECKS_GRACE.as_secs() / 60
355            ),
356        },
357        // Red, but the forge says it does not stand in the way: the failing
358        // checks are ones this repository chose not to require. Spending a fix
359        // round on them asks an agent to repair something nobody is gating on
360        // - and pull request 37's only red check was an *action* that could
361        // not fetch its own binary. Merge, and name them so the record is
362        // honest about what was red when it landed.
363        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
364        Checks::Red => {
365            let what = format!(
366                "{} check(s) failing: {}",
367                pr.failing.len(),
368                pr.failing.join(", ")
369            );
370            if spent {
371                Step::GiveUp {
372                    reason: format!("{what} — still red after {budget} fix round(s)"),
373                }
374            } else {
375                Step::Fix { reason: what }
376            }
377        }
378        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
379        Checks::Green => {
380            let what = format!(
381                "checks are green but {} review comment(s) are unresolved: {}",
382                pr.review_comments.len(),
383                authors(&pr.review_comments)
384            );
385            if spent {
386                Step::GiveUp {
387                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
388                }
389            } else {
390                Step::Fix { reason: what }
391            }
392        }
393    }
394}
395
396/// Distinct comment authors, in the order they first appear.
397fn authors(comments: &[ReviewComment]) -> String {
398    let mut seen: Vec<&str> = Vec::new();
399    for c in comments {
400        if !seen.contains(&c.author.as_str()) {
401            seen.push(&c.author);
402        }
403    }
404    seen.join(", ")
405}
406
407/// The argv magi merges with, minus the program name.
408///
409/// `--subject` is the point of this function existing: see the module docs.
410pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
411    vec![
412        "pr".to_owned(),
413        "merge".to_owned(),
414        number.to_string(),
415        "--squash".to_owned(),
416        "--delete-branch".to_owned(),
417        "--subject".to_owned(),
418        subject.to_owned(),
419    ]
420}
421
422/// The squash subject to merge under.
423///
424/// The pull request title, unless it is empty or is a candidate branch's commit
425/// subject that leaked into the title - in which case the task's own first line
426/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
427/// reader nothing about what landed.
428pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
429    let title = pr_title.trim();
430    if !title.is_empty() && !title.starts_with("magi: candidate") {
431        return title.to_owned();
432    }
433    let first = instruction
434        .lines()
435        .map(str::trim)
436        .find(|l| !l.is_empty())
437        .unwrap_or("magi: land the winning candidate");
438    first.trim_start_matches(['#', ' ']).to_owned()
439}
440
441/// The choice that lets the merge happen, verbatim as the owner taps it.
442pub const APPROVE: &str = "merge";
443
444/// The choice that leaves the pull request open.
445pub const HOLD: &str = "hold";
446
447/// Graph node recorded on the approval question.
448///
449/// The phone keys its high-stakes card off this rather than off the choice
450/// strings, so renaming a button cannot silently downgrade the card that
451/// guards the one irreversible action magi takes.
452pub const APPROVAL_NODE: &str = "land-approval";
453
454/// Unified diff lines carried in the panel before it is truncated.
455///
456/// Four hundred: the panel is read on a 390px phone, where a diff line often
457/// wraps to two rows, so this is already a few thousand rows of scrolling -
458/// past that nobody is reading, and the bytes still count against the panel's
459/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
460/// cut and which worktree holds the whole patch.
461pub const DIFF_MAX_LINES: usize = 400;
462
463/// What the owner's answer to the approval question means.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum Approval {
466    /// The owner said [`APPROVE`]. Merge.
467    Merge,
468    /// Anything else, including silence. Leave the pull request open.
469    Hold,
470}
471
472/// Read the owner's answer, where `None` is an unanswered question.
473///
474/// Silence is a hold. A timed-out question means the owner never saw it or
475/// never decided, and defaulting an irreversible merge to "yes" would make this
476/// gate worse than no gate at all: it would merge unattended while claiming to
477/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
478/// function does not recognise holds too.
479pub fn approval(answer: Option<&str>) -> Approval {
480    match answer {
481        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
482        _ => Approval::Hold,
483    }
484}
485
486/// What [`approval_gate`] found on one check of the owner's merge decision.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum ApprovalGate {
489    /// The owner said [`APPROVE`]. Merge.
490    Approved,
491    /// The owner said anything else, the question timed out, or it was
492    /// closed with no decision recorded.
493    Held,
494    /// Filed and still waiting - the caller parks rather than blocking on it.
495    Pending,
496}
497
498/// Escape text for HTML, including both quote characters.
499///
500/// Every string in the panel is agent-influenced: a branch name, a file path, a
501/// commit subject, a review comment. The sandboxed frame stops such text from
502/// *running*, but it does not stop a `<` from ending the document early or a
503/// `"` from ending an attribute and inventing a new one - the panel would then
504/// render a lie, or not render at all. Both quotes are escaped because the same
505/// function is used inside attributes, where remembering which quote style the
506/// caller used is one mistake away from an injected attribute.
507fn esc(s: &str) -> String {
508    let mut out = String::with_capacity(s.len());
509    for c in s.chars() {
510        match c {
511            '&' => out.push_str("&amp;"),
512            '<' => out.push_str("&lt;"),
513            '>' => out.push_str("&gt;"),
514            '"' => out.push_str("&quot;"),
515            '\'' => out.push_str("&#39;"),
516            _ => out.push(c),
517        }
518    }
519    out
520}
521
522/// One row of the diffstat table.
523#[derive(Debug, Clone, PartialEq, Eq)]
524struct StatRow {
525    path: String,
526    /// `None` for a binary file, which `git` reports as `-`.
527    added: Option<u64>,
528    removed: Option<u64>,
529}
530
531impl StatRow {
532    /// Lines touched, for sorting. A binary file counts as zero rather than as
533    /// unknown, which puts it at the bottom where it needs no attention.
534    fn churn(&self) -> u64 {
535        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
536    }
537}
538
539/// Parse `git diff --numstat` into rows, biggest churn first.
540///
541/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
542/// terminal width, so counting its characters would print fabricated numbers in
543/// the one table an operator approves an irreversible action from.
544fn parse_numstat(numstat: &str) -> Vec<StatRow> {
545    let mut rows: Vec<StatRow> = numstat
546        .lines()
547        .filter_map(|line| {
548            let mut parts = line.splitn(3, '\t');
549            let added = parts.next()?.trim();
550            let removed = parts.next()?.trim();
551            let path = parts.next()?.trim();
552            if path.is_empty() {
553                return None;
554            }
555            Some(StatRow {
556                path: path.to_owned(),
557                added: added.parse().ok(),
558                removed: removed.parse().ok(),
559            })
560        })
561        .collect();
562    // Path breaks the tie so the same change always renders the same table; an
563    // operator comparing two panels should not see rows shuffle.
564    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
565    rows
566}
567
568/// How one diff line is shown: a gutter character, a style, and the body to
569/// print - which is the line minus its marker, so the marker appears exactly
570/// once, in the gutter.
571///
572/// The gutter is why this exists at all. The operator may be colour blind, or
573/// reading in sunlight with the screen dimmed, so an added line is never
574/// distinguished by its background alone: `+` and `-` sit in a fixed column,
575/// the same mark they already read in a terminal.
576fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
577    if line.starts_with("+++") || line.starts_with("---") {
578        (" ", "color:#57606a;font-weight:600", line)
579    } else if let Some(body) = line.strip_prefix('+') {
580        ("+", "background:#e6ffec;color:#0a3622", body)
581    } else if let Some(body) = line.strip_prefix('-') {
582        ("-", "background:#ffebe9;color:#5c1a17", body)
583    } else if line.starts_with("@@") {
584        ("~", "background:#eef2ff;color:#3730a3", line)
585    } else if let Some(body) = line.strip_prefix(' ') {
586        (" ", "", body)
587    } else {
588        (" ", "color:#57606a;font-weight:600", line)
589    }
590}
591
592/// The handful of words the approval panel says in its own voice.
593///
594/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
595/// the operator asked why the merge question spoke English on a repository
596/// configured for Japanese, and "because that string is a literal in Rust" is
597/// not an answer. Only the languages magi can actually check are translated;
598/// anything else falls back to English rather than shipping a guess, and that
599/// fallback is deliberate.
600struct Words {
601    html_lang: &'static str,
602    task: &'static str,
603    what_changed: &'static str,
604    review_verdict: &'static str,
605    reviewer: &'static str,
606    reviewer_no_answer: &'static str,
607    checks: &'static str,
608    nothing_failing: &'static str,
609    files_changed: &'static str,
610    commits: &'static str,
611    no_commits: &'static str,
612    comments: &'static str,
613    no_comments: &'static str,
614    diff: &'static str,
615    truncated: &'static str,
616    lands_as: &'static str,
617}
618
619const EN: Words = Words {
620    html_lang: "en",
621    task: "Task",
622    what_changed: "What changed",
623    review_verdict: "Review verdict",
624    reviewer: "Reviewer",
625    reviewer_no_answer: "produced no answer",
626    checks: "Checks",
627    nothing_failing: "Nothing failing.",
628    files_changed: "file(s) changed",
629    commits: "Commits being squashed",
630    no_commits: "No commit subjects could be read from the branch.",
631    comments: "Review comments",
632    no_comments: "Nothing outstanding at this observation.",
633    diff: "Diff",
634    truncated: "Truncated",
635    lands_as: "They land as one commit titled",
636};
637
638const JA: Words = Words {
639    html_lang: "ja",
640    task: "タスク",
641    what_changed: "変更内容",
642    review_verdict: "レビューの結論",
643    reviewer: "レビュアー",
644    reviewer_no_answer: "回答なし",
645    checks: "チェック",
646    nothing_failing: "失敗しているものはありません。",
647    files_changed: "ファイル変更",
648    commits: "squash されるコミット",
649    no_commits: "ブランチからコミット件名を読めませんでした。",
650    comments: "レビューコメント",
651    no_comments: "この時点で未対応のものはありません。",
652    diff: "差分",
653    truncated: "省略",
654    lands_as: "これらは次の件名の1コミットとして入ります:",
655};
656
657impl Words {
658    /// The clause after the merge subject. Split out because word order moves:
659    /// Japanese puts the subject before the verb, so a shared template with a
660    /// hole in the middle would read as machine translation.
661    fn lands_as_tail(&self) -> &'static str {
662        if self.html_lang == "ja" {
663            "。この件名も承認の対象です。"
664        } else {
665            ", which you are approving too."
666        }
667    }
668
669    /// The question's own one-line summary, which is what a phone shows first.
670    fn approval_summary(&self, number: u64, subject: &str) -> String {
671        if self.html_lang == "ja" {
672            format!("プルリクエスト #{number} をマージ: {subject}")
673        } else {
674            format!("merge pull request #{number}: {subject}")
675        }
676    }
677
678    /// The body under the summary, above the panel.
679    fn approval_detail(&self, url: &str, base: &str, subject: &str) -> String {
680        if self.html_lang == "ja" {
681            format!(
682                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
683                 できる状態です。差分の要約・パッチ・squash されるコミットは\
684                 下のパネルにあります。"
685            )
686        } else {
687            format!(
688                "{url} is green and ready to squash into `{base}` as `{subject}`. \
689                 The panel holds the diffstat, the patch and the commits being squashed."
690            )
691        }
692    }
693
694    /// The truncation note, written whole in each language for the same reason.
695    fn truncated_note(
696        &self,
697        omitted: usize,
698        total: usize,
699        shown: usize,
700        where_: &str,
701        base: &str,
702        head: &str,
703    ) -> String {
704        if self.html_lang == "ja" {
705            format!(
706                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
707                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
708                 プルリクエストにあります。"
709            )
710        } else {
711            format!(
712                "{omitted} of {total} diff lines omitted after the first {shown}. \
713                 The whole patch is in <code>{where_}</code> \
714                 (<code>git diff {base}...{head}</code>) and on the pull request."
715            )
716        }
717    }
718}
719
720/// Pick the panel's language. Codes and names both, because `[graph] language`
721/// has always accepted either.
722fn words(language: &str) -> &'static Words {
723    let l = language.trim();
724    if l.eq_ignore_ascii_case("ja")
725        || l.eq_ignore_ascii_case("jp")
726        || l.eq_ignore_ascii_case("japanese")
727        || l.eq_ignore_ascii_case("日本語")
728    {
729        &JA
730    } else {
731        &EN
732    }
733}
734
735/// The approval panel's html: what is about to land, and the evidence for it.
736///
737/// Pure, so the whole document is asserted in tests without `gh`, without a
738/// network and without a repository. The caller gathers `diffstat`
739/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
740/// being squashed) and `subject` (what the squash will be called) from the
741/// winner's worktree.
742///
743/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
744/// content security policy blocks all three: anything of the sort here would be
745/// dead markup that misleads the next reader into thinking it works.
746pub fn approval_panel(
747    state: &RunState,
748    pr: &PrState,
749    diffstat: &str,
750    diff: &str,
751    commits: &[String],
752    subject: &str,
753) -> String {
754    let rows = parse_numstat(diffstat);
755    let w = words(&state.config.graph.language);
756    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
757
758    let _ = writeln!(
759        h,
760        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
761         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
762        w.html_lang
763    );
764    let _ = writeln!(
765        h,
766        "<title>merge #{} — {}</title>\n</head>",
767        pr.number,
768        esc(subject)
769    );
770    h.push_str(
771        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
772         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
773         word-break:break-word\">\n",
774    );
775
776    // The decision, in the words the operator is approving.
777    let _ = writeln!(
778        h,
779        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
780         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
781         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
782         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
783         <a href=\"{}\" style=\"color:#0969da\">{}</a></p>",
784        pr.number,
785        esc(&state.base_branch),
786        esc(subject),
787        esc(&state.id),
788        esc(&pr.url),
789        esc(&pr.url),
790    );
791
792    // The task, verbatim: the operator's own words for what was asked, so the
793    // panel does not make them reconstruct the request from a diffstat.
794    let _ = writeln!(
795        h,
796        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
797         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
798        w.task,
799        esc(&state.instruction)
800    );
801
802    // The winner's own account of what it did and why, when there is one.
803    if let Some(summary) = state
804        .winner()
805        .map(|c| c.summary.as_str())
806        .filter(|s| !s.is_empty())
807    {
808        let _ = writeln!(
809            h,
810            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
811             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
812            w.what_changed,
813            esc(summary)
814        );
815    }
816
817    // The verdict from the round that actually cleared this for merge - the
818    // last one, since only that round's word is still standing.
819    if let Some(round) = state.reviews.last() {
820        let _ = writeln!(
821            h,
822            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
823            w.review_verdict
824        );
825        for r in &round.reviews {
826            // A seat the review loop counted as answered has real prose in
827            // `summary`; one it counted against `incomplete` (see
828            // `graph::Runner::review_loop`) never produced any and left it
829            // empty - which must not be read back as a blank verdict, since
830            // an empty box here looks like "nothing to say" rather than
831            // "never answered".
832            let body = match &r.failed {
833                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
834                None => esc(&r.summary),
835            };
836            let _ = writeln!(
837                h,
838                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
839                 border-radius:6px\">\
840                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
841                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
842                w.reviewer,
843                r.reviewer,
844                esc(&r.agent),
845                body,
846            );
847        }
848    }
849
850    let _ = writeln!(
851        h,
852        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
853        w.checks,
854        esc(pr.checks.as_str())
855    );
856    if pr.failing.is_empty() {
857        let _ = writeln!(
858            h,
859            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
860            w.nothing_failing
861        );
862    } else {
863        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
864        for f in &pr.failing {
865            let _ = writeln!(h, "<li>{}</li>", esc(f));
866        }
867        h.push_str("</ul>\n");
868    }
869
870    // Diffstat as a real table, so a phone reads what moved without scrolling
871    // sideways through a terminal bar chart.
872    let _ = writeln!(
873        h,
874        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
875        rows.len(),
876        w.files_changed
877    );
878    h.push_str(
879        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
880         <thead><tr>\
881         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
882         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
883         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
884         </th></tr></thead>\n<tbody>\n",
885    );
886    let mut total_added = 0u64;
887    let mut total_removed = 0u64;
888    for r in &rows {
889        total_added += r.added.unwrap_or(0);
890        total_removed += r.removed.unwrap_or(0);
891        let cell = |n: Option<u64>| match n {
892            Some(n) => n.to_string(),
893            None => "bin".to_owned(),
894        };
895        let _ = writeln!(
896            h,
897            "<tr>\
898             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
899             font-family:ui-monospace,monospace\">{}</td>\
900             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
901             color:#0a3622\">{}</td>\
902             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
903             color:#5c1a17\">{}</td></tr>",
904            esc(&r.path),
905            cell(r.added),
906            cell(r.removed),
907        );
908    }
909    let _ = writeln!(
910        h,
911        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
912         <td style=\"padding:4px 2px\">total</td>\
913         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
914         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
915         </tr></tfoot>\n</table>"
916    );
917
918    // The commits being squashed, and the subject that replaces them.
919    let _ = writeln!(
920        h,
921        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
922        w.commits
923    );
924    if commits.is_empty() {
925        h.push_str(&format!(
926            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
927            w.no_commits
928        ));
929    } else {
930        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
931        for c in commits {
932            let _ = writeln!(h, "<li>{}</li>", esc(c));
933        }
934        h.push_str("</ol>\n");
935    }
936    let _ = writeln!(
937        h,
938        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
939        w.lands_as,
940        esc(subject),
941        w.lands_as_tail()
942    );
943
944    // The review comments that shaped this branch, and who asked for them.
945    let _ = writeln!(
946        h,
947        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
948        w.comments
949    );
950    if pr.review_comments.is_empty() {
951        h.push_str(&format!(
952            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
953            w.no_comments
954        ));
955    } else {
956        for c in &pr.review_comments {
957            let anchor = match (&c.path, c.line) {
958                (Some(p), Some(l)) => format!("{p}:{l}"),
959                (Some(p), None) => p.clone(),
960                _ => "pull request thread".to_owned(),
961            };
962            let _ = writeln!(
963                h,
964                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
965                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
966                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
967                esc(&c.author),
968                esc(&anchor),
969                esc(&tail(&c.body, 800)),
970            );
971        }
972    }
973
974    // The patch itself.
975    let total = diff.lines().count();
976    let shown = total.min(DIFF_MAX_LINES);
977    let _ = writeln!(
978        h,
979        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
980        w.diff
981    );
982    h.push_str(
983        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
984         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
985    );
986    for line in diff.lines().take(shown) {
987        let (gutter, style, body) = diff_row(line);
988        let _ = writeln!(
989            h,
990            "<div style=\"display:flex;{style}\">\
991             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
992             border-right:1px solid #d0d7de\">{gutter}</span>\
993             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
994            esc(body),
995        );
996    }
997    h.push_str("</div>\n");
998    if total > shown {
999        let omitted = total - shown;
1000        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1001        let where_ = state.winner().map_or_else(
1002            || state.repo.display().to_string(),
1003            |w| w.worktree.display().to_string(),
1004        );
1005        let _ = writeln!(
1006            h,
1007            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1008             font-size:13px\">{}: {}</p>",
1009            w.truncated,
1010            w.truncated_note(
1011                omitted,
1012                total,
1013                shown,
1014                &esc(&where_),
1015                &esc(&state.base_branch),
1016                &esc(head),
1017            ),
1018        );
1019    }
1020
1021    h.push_str("</body>\n</html>\n");
1022    h
1023}
1024
1025/// Ask the owner before merging, with the whole case attached as a panel.
1026///
1027/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1028/// never from the network, so a phone on a slow link gets the diff magi is
1029/// looking at rather than a link it has to go and open.
1030///
1031/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1032/// for up to a day right here, which held the whole run's task claim - and
1033/// the daemon's one slot with it - for exactly as long as the owner took to
1034/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1035/// caller park the run and hand the slot back instead: the question is on
1036/// disk either way, so nothing about the wait itself changes, only who is
1037/// blocked on it.
1038///
1039/// Idempotent across resumes: called again for a run already waiting on its
1040/// own question, this finds that question by [`crate::ask::Questions::list`]
1041/// rather than filing a second one - asking twice would double the
1042/// notification for one decision, and leave the first question's panel an
1043/// orphan nobody's answer ever reaches.
1044async fn approval_gate(state: &mut RunState, pr: &PrState, subject: &str) -> Result<ApprovalGate> {
1045    let store = ask::Questions::open();
1046    let existing = store
1047        .list()
1048        .into_iter()
1049        .filter(|q| q.run == state.id && q.node == APPROVAL_NODE)
1050        .max_by(|a, b| a.id.cmp(&b.id));
1051
1052    let q = match existing {
1053        Some(q) => q,
1054        None => {
1055            let (worktree, head) = match state.winner() {
1056                Some(w) => (w.worktree.clone(), w.branch.clone()),
1057                None => (state.repo.clone(), "HEAD".to_owned()),
1058            };
1059            let base = state.base_branch.clone();
1060            let range = format!("{base}...{head}");
1061            // A failed `git` must not decide the merge: the panel degrades to
1062            // less evidence and the owner still chooses. Merging because the
1063            // diff could not be read would be the worst of both.
1064            let numstat = git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1065                .await
1066                .map(|o| o.stdout)
1067                .unwrap_or_default();
1068            let diff = git::diff(&worktree, &base, &head).await.unwrap_or_default();
1069            let commits: Vec<String> = git::git_raw(
1070                &worktree,
1071                &[
1072                    "log",
1073                    "--reverse",
1074                    "--format=%s",
1075                    &format!("{base}..{head}"),
1076                ],
1077            )
1078            .await
1079            .map(|o| o.stdout)
1080            .unwrap_or_default()
1081            .lines()
1082            .filter(|l| !l.trim().is_empty())
1083            .map(str::to_owned)
1084            .collect();
1085
1086            let w = words(&state.config.graph.language);
1087            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1088            let mut fresh = ask::Question::new(
1089                state.id.clone(),
1090                APPROVAL_NODE.to_owned(),
1091                "land".to_owned(),
1092                w.approval_summary(pr.number, subject),
1093                w.approval_detail(&pr.url, &base, subject),
1094                vec![APPROVE.to_owned(), HOLD.to_owned()],
1095            );
1096            store
1097                .put_panel(&mut fresh, &html, &[])
1098                .context("write the merge approval panel")?;
1099            store
1100                .put(&mut fresh)
1101                .context("file the merge approval question")?;
1102            state.event(
1103                "land",
1104                format!("asking for merge approval ({})", fresh.short()),
1105            );
1106            state.save()?;
1107            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1108                // A broken webhook is not a reason to lose the merge: the
1109                // question is already on disk and the web UI already shows
1110                // it, so the operator still has a way in.
1111                tracing::warn!(
1112                    "could not notify about merge approval question {}: {e:#} - \
1113                     the web UI is the only surface for it now",
1114                    fresh.short()
1115                );
1116            }
1117            fresh
1118        }
1119    };
1120
1121    Ok(match q.status {
1122        ask::QuestionStatus::Open => ApprovalGate::Pending,
1123        // Nobody answered before `state.config.graph.answer_timeout` passed,
1124        // or the question was closed with no decision recorded underneath
1125        // this run - either way there is nothing left to wait on.
1126        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1127        // The merge gate does not speak `--thread`: an owner who talked back
1128        // instead of choosing never reaches `Answered`, so this arm only
1129        // ever sees an actual decision.
1130        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1131            Approval::Merge => ApprovalGate::Approved,
1132            Approval::Hold => ApprovalGate::Held,
1133        },
1134    })
1135}
1136
1137/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1138/// output into a [`PrState`]. No I/O.
1139pub fn parse_pr(json: &str) -> Result<PrState> {
1140    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1141    let state = match raw.state.to_ascii_uppercase().as_str() {
1142        "OPEN" => PrLifecycle::Open,
1143        "MERGED" => PrLifecycle::Merged,
1144        "CLOSED" => PrLifecycle::Closed,
1145        other => bail!("unknown pull request state `{other}`"),
1146    };
1147
1148    let mut failing = Vec::new();
1149    let mut pending = false;
1150    let mut unknown = false;
1151    for check in &raw.status_check_rollup {
1152        match check.verdict() {
1153            Verdict::Pass => {}
1154            Verdict::Pending => pending = true,
1155            Verdict::Fail => failing.push(check.label()),
1156            Verdict::Unknown => unknown = true,
1157        }
1158    }
1159    let checks = if raw.status_check_rollup.is_empty() {
1160        Checks::Unknown
1161    } else if pending {
1162        Checks::Pending
1163    } else if !failing.is_empty() {
1164        Checks::Red
1165    } else if unknown {
1166        Checks::Unknown
1167    } else {
1168        Checks::Green
1169    };
1170
1171    let mut review_comments = Vec::new();
1172    for r in raw.reviews {
1173        push_if_outstanding(
1174            &mut review_comments,
1175            ReviewComment {
1176                author: r.author.login,
1177                path: None,
1178                line: None,
1179                body: r.body,
1180            },
1181        );
1182    }
1183    for c in raw.comments {
1184        push_if_outstanding(
1185            &mut review_comments,
1186            ReviewComment {
1187                author: c.author.login,
1188                path: None,
1189                line: None,
1190                body: c.body,
1191            },
1192        );
1193    }
1194
1195    Ok(PrState {
1196        url: raw.url,
1197        number: raw.number,
1198        state,
1199        checks,
1200        failing,
1201        review_comments,
1202        blocking: Blocking::of(&raw.merge_state_status),
1203    })
1204}
1205
1206/// Read just a pull request's lifecycle state - open, merged, or closed -
1207/// with none of the checks/reviews/comments [`land`] itself needs to decide
1208/// what to do next.
1209///
1210/// For a caller that only ever wants one fact and must not risk anything
1211/// else: `magi fold --merged` uses this to confirm a URL the operator hands
1212/// it is actually a merged pull request *before* touching a run's state, so a
1213/// typo or a still-open PR fails loudly instead of quietly recording a merge
1214/// that never happened.
1215pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1216    let view = gh(
1217        repo,
1218        &[
1219            "pr".to_owned(),
1220            "view".to_owned(),
1221            pr_url.to_owned(),
1222            "--json".to_owned(),
1223            "state".to_owned(),
1224        ],
1225    )
1226    .await?;
1227    if !view.0 {
1228        bail!("gh pr view {pr_url}: {}", view.1);
1229    }
1230    // `parse_pr` reads every other field of `GhPr` as its serde default
1231    // (empty string, empty vec, zero) when this narrower `--json` selection
1232    // does not carry them - harmless, since only `.state` is read back.
1233    Ok(parse_pr(&view.1)?.state)
1234}
1235
1236/// A pull request the operator merged outside of `land::land`'s own loop,
1237/// found by asking GitHub about the run's own winning branch rather than
1238/// requiring the operator to go and find the URL themselves.
1239#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1240pub struct ExternalMerge {
1241    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
1242    pub url: String,
1243    /// The pull request's number.
1244    pub number: u64,
1245}
1246
1247#[derive(Debug, Deserialize)]
1248#[serde(rename_all = "camelCase")]
1249struct GhMergedPr {
1250    url: String,
1251    number: u64,
1252    merged_at: String,
1253    base_ref_name: String,
1254}
1255
1256/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
1257/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
1258/// decide whether exactly one of the pull requests it lists could actually
1259/// be *this* run's.
1260///
1261/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
1262/// from the run's own short id, so a collision with some other, unrelated
1263/// task's merged pull request from a same-named branch is rare but not
1264/// impossible once branches are deleted and ids run out. Filtering on
1265/// `base_ref_name` (the branch this run actually targets) and `merged_at`
1266/// (which cannot predate the run itself) rules that case out. More than one
1267/// survivor is exactly as uninformative as zero — something this run cannot
1268/// tell apart from another — so only a unique survivor is returned.
1269fn pick_merged_pr(
1270    json: &str,
1271    base_branch: &str,
1272    created_at: Timestamp,
1273) -> Result<Option<ExternalMerge>> {
1274    let raw: Vec<GhMergedPr> =
1275        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1276    let mut matches: Vec<ExternalMerge> = Vec::new();
1277    for pr in raw {
1278        if pr.base_ref_name != base_branch {
1279            continue;
1280        }
1281        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1282            continue;
1283        };
1284        if merged_at < created_at {
1285            continue;
1286        }
1287        matches.push(ExternalMerge {
1288            url: pr.url,
1289            number: pr.number,
1290        });
1291    }
1292    if matches.len() == 1 {
1293        Ok(matches.pop())
1294    } else {
1295        Ok(None)
1296    }
1297}
1298
1299/// Ask GitHub whether this run's winning candidate branch was actually merged
1300/// somewhere `land::land`'s own loop never saw — the gap `magi fold
1301/// --merged` exists to close, minus the operator having to find the URL by
1302/// hand.
1303///
1304/// `Ok(None)` covers every case where nothing can be said with confidence: no
1305/// winner decided yet (nothing to check a branch for), no merged pull request
1306/// found, or [`pick_merged_pr`] found more than one candidate and would not
1307/// guess between them. Never wired to a weaker, URL-less signal like
1308/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
1309/// separately, precisely because it cannot drive an automatic correction on
1310/// its own (see that function's own doc).
1311pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
1312    let Some(winner) = state.winner() else {
1313        return Ok(None);
1314    };
1315    let branch = winner.branch.clone();
1316    let out = gh(
1317        &state.repo,
1318        &[
1319            "pr".to_owned(),
1320            "list".to_owned(),
1321            "--head".to_owned(),
1322            branch.clone(),
1323            "--state".to_owned(),
1324            "merged".to_owned(),
1325            "--json".to_owned(),
1326            "url,number,mergedAt,baseRefName".to_owned(),
1327        ],
1328    )
1329    .await?;
1330    if !out.0 {
1331        bail!("gh pr list --head {branch}: {}", out.1);
1332    }
1333    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
1334}
1335
1336/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
1337/// git graph — the weaker, URL-less signal that a branch landed somewhere.
1338///
1339/// Deliberately never consulted by [`find_external_merge`]: a base branch
1340/// that has moved since the run started can make an old, abandoned branch
1341/// look like an ancestor of the *current* base for reasons that have nothing
1342/// to do with a merge (a later commit that happens to supersede it, an
1343/// unrelated squash), and there is no pull request URL here to confirm
1344/// against or to land through anyway. Its only honest use is a weaker
1345/// notice — "this looks merged, go check" — never an automatic rewrite of
1346/// `status`/`merge`.
1347pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
1348    let out = tokio::process::Command::new("git")
1349        .args(["merge-base", "--is-ancestor", branch, base_branch])
1350        .current_dir(repo)
1351        .quiet()
1352        .stdin(std::process::Stdio::null())
1353        .output()
1354        .await
1355        .context("spawn git merge-base --is-ancestor")?;
1356    Ok(out.status.success())
1357}
1358
1359/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
1360/// `status` and `merge` exactly as the automatic land loop (`land::land`)
1361/// would have written them had magi opened and merged this pull request
1362/// itself.
1363///
1364/// This is `magi fold --merged`'s whole implementation, and also what the
1365/// automatic janitor sweep (`clean::housekeep`) and the web `fold-merged`
1366/// route call once they have a URL in hand — one operator recovery path for
1367/// a merge magi could not finish on its own: a PR title too long for the
1368/// GraphQL mutation, `gh pr create` unreachable, a stale token - closed by
1369/// hand with a pull request magi never opened and so never recorded.
1370/// Reusing `land::land` rather than writing `status`/`merge` directly keeps
1371/// this one authoritative: a merged pull request decides
1372/// `Step::Done { merged: true }` on the very first read, before any of
1373/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
1374/// safe to call here even though this pull request was never magi's own.
1375/// [`lifecycle`] is checked first and separately so a mistyped or still-open
1376/// URL fails loudly without writing anything, rather than handing an open
1377/// pull request to the full autonomous loop by accident.
1378///
1379/// Correcting `status` this way does not run `bump::after_merge`
1380/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
1381/// which this path never goes through. A release version bump the change
1382/// might have earned is therefore not filed automatically and has to be
1383/// requested by hand - recorded as an event on the run so the gap is visible
1384/// to whoever reads it later, not just wherever this was called from.
1385///
1386/// Returns the status before and after, so every caller (CLI, janitor, web
1387/// route) can build its own log line or response from the same pair rather
1388/// than each re-deriving it.
1389pub async fn correct_manual_merge(
1390    state: &mut RunState,
1391    url: &str,
1392) -> Result<(RunStatus, RunStatus)> {
1393    match lifecycle(&state.repo, url).await? {
1394        PrLifecycle::Merged => {}
1395        other => bail!(
1396            "{url} is {}, not merged; refusing to record {} as merged on a guess",
1397            other.as_str(),
1398            state.id
1399        ),
1400    }
1401    let before = state.status;
1402    if let Err(e) = land(state, url).await {
1403        // `land` sets `status` to `Landing` and saves before its first read
1404        // of the pull request — see its own doc — so a failure here (a
1405        // transient `gh` hiccup between the two forge reads this function
1406        // makes) can leave the run stuck on that in-between value with
1407        // nothing left driving it. Land it on the same terminal shape an
1408        // automated `land` failure lands on instead of leaving it stuck.
1409        state.status = RunStatus::Blocked;
1410        state.event("fold", format!("manual-merge correction failed: {e:#}"));
1411        state.save()?;
1412        return Err(e).context(format!("confirming the merge of {url}"));
1413    }
1414    state.event(
1415        "fold",
1416        "operator recorded this pull request as a manual merge; this run never \
1417         re-entered `land`, so `bump::after_merge` did not run for it - a release \
1418         bump this change might warrant has to be filed by hand",
1419    );
1420    state.save()?;
1421    Ok((before, state.status))
1422}
1423
1424/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
1425/// comments. No I/O.
1426///
1427/// `gh pr view` does not surface inline comments, and inline is exactly where
1428/// both review bots put their findings - a landing loop that read only the
1429/// top-level thread would never see the thing it is supposed to fix.
1430pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
1431    let raw: Vec<GhInline> =
1432        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
1433    let mut out = Vec::new();
1434    for c in raw {
1435        push_if_outstanding(
1436            &mut out,
1437            ReviewComment {
1438                author: c.user.login,
1439                path: c.path,
1440                line: c.line,
1441                body: c.body,
1442            },
1443        );
1444    }
1445    Ok(out)
1446}
1447
1448/// Keep a comment only when it asks for something.
1449///
1450/// An inline comment always does: it names a file and a line. A top-level
1451/// comment is dropped when it is empty, when it is magi's own, or when it is
1452/// [noise](is_noise).
1453fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
1454    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
1455        return;
1456    }
1457    if comment.path.is_none() && is_noise(&comment.body) {
1458        return;
1459    }
1460    out.push(comment);
1461}
1462
1463/// Is this comment body machinery rather than a finding?
1464///
1465/// Two tests, both structural, because guessing from prose is how a "looks
1466/// good to me" turns into a fix round:
1467///
1468/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
1469///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
1470///    footer.
1471/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
1472///    horizontal rules, and the bot's own status banner are removed, every
1473///    remaining line is a task-list item. That is exactly the shape of the
1474///    comment the Claude review job posts while it is still working.
1475///
1476/// Anything else is input, including bot prose. A bot that writes a paragraph
1477/// has said something, and the fix prompt tells the fixer it may decline a
1478/// comment with an argument - a wasted sentence in a prompt is cheaper than a
1479/// missed finding.
1480pub fn is_noise(body: &str) -> bool {
1481    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
1482        return true;
1483    }
1484    let mut content = false;
1485    for line in strip_blocks(body).lines() {
1486        let line = unquote(line);
1487        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
1488            continue;
1489        }
1490        content = true;
1491        break;
1492    }
1493    !content
1494}
1495
1496/// Remove HTML comments and collapsed `<details>` blocks.
1497fn strip_blocks(body: &str) -> String {
1498    let mut out = String::with_capacity(body.len());
1499    let mut rest = body;
1500    loop {
1501        let open = ["<!--", "<details>"]
1502            .iter()
1503            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
1504            .min_by_key(|(i, _)| *i);
1505        let Some((at, tag)) = open else {
1506            out.push_str(rest);
1507            return out;
1508        };
1509        out.push_str(&rest[..at]);
1510        let after = &rest[at + tag.len()..];
1511        let close = if tag == "<!--" { "-->" } else { "</details>" };
1512        match after.find(close) {
1513            Some(end) => rest = &after[end + close.len()..],
1514            // Unterminated: the rest of the body is inside the block.
1515            None => return out,
1516        }
1517    }
1518}
1519
1520/// Strip blockquote markers, which both bots wrap their callouts in.
1521fn unquote(line: &str) -> &str {
1522    let mut s = line.trim();
1523    while let Some(rest) = s.strip_prefix('>') {
1524        s = rest.trim_start();
1525    }
1526    s.trim()
1527}
1528
1529/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
1530fn is_checklist(line: &str) -> bool {
1531    let rest = line
1532        .strip_prefix("- ")
1533        .or_else(|| line.strip_prefix("* "))
1534        .unwrap_or("");
1535    let rest = rest.trim_start();
1536    matches!(
1537        rest.get(..3),
1538        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
1539    )
1540}
1541
1542/// A heading, a horizontal rule, or a callout tag - shape, never content.
1543fn is_decoration(line: &str) -> bool {
1544    line.starts_with('#')
1545        || line.starts_with("[!")
1546        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
1547}
1548
1549/// A line that is nothing but emphasis and links.
1550///
1551/// Both review jobs open with a status banner
1552/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
1553/// to a line-based test and asks for nothing, so it is measured the same way a
1554/// heading is: strip the markup, and if no word survives, it was decoration.
1555fn is_banner(line: &str) -> bool {
1556    let plain = drop_spans(line, "**", "**");
1557    let plain = if plain.contains("](") {
1558        drop_spans(&plain, "[", ")")
1559    } else {
1560        plain
1561    };
1562    !plain.chars().any(char::is_alphanumeric)
1563}
1564
1565/// Remove every `open` .. `close` span, including the delimiters. An
1566/// unterminated span swallows the rest of the input, which is what a reader
1567/// sees too.
1568fn drop_spans(s: &str, open: &str, close: &str) -> String {
1569    let mut out = String::with_capacity(s.len());
1570    let mut rest = s;
1571    while let Some(at) = rest.find(open) {
1572        out.push_str(&rest[..at]);
1573        let after = &rest[at + open.len()..];
1574        match after.find(close) {
1575            Some(end) => rest = &after[end + close.len()..],
1576            None => return out,
1577        }
1578    }
1579    out.push_str(rest);
1580    out
1581}
1582
1583/// The lock that keeps at most one run per repository actually moving the
1584/// base branch at a time: a rebase push, or `gh pr merge`.
1585///
1586/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
1587/// running a fix round in the winner's own worktree, waiting on the owner's
1588/// approval - touches nothing a *different* run in the same repository could
1589/// collide with, and holding a lock across any of that would serialise one
1590/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
1591/// resume, which is precisely the "must not wait on another task" property
1592/// the daemon's slot-freeing exists to give a resume. Only the two moments
1593/// that actually write to the shared base branch need mutual exclusion, and
1594/// both are brief.
1595///
1596/// One entry per repository, each its own `tokio::sync::Mutex`, so two
1597/// different repositories' runs never wait on each other. The outer
1598/// `std::sync::Mutex` guards only the map itself, held long enough to find or
1599/// insert an entry and clone its `Arc`, never across an `.await`.
1600fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
1601    static LOCKS: std::sync::LazyLock<
1602        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
1603    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
1604    LOCKS
1605        .lock()
1606        .unwrap_or_else(std::sync::PoisonError::into_inner)
1607        .entry(repo.to_path_buf())
1608        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
1609        .clone()
1610}
1611
1612/// Run the loop against a real pull request until it merges or the budget runs
1613/// out.
1614///
1615/// The caller decides whether landing happens at all: this is only reached when
1616/// `graph.land` is on. Returns the last observation, so the caller can report
1617/// what magi was looking at when it stopped.
1618pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
1619    let repo = state.repo.clone();
1620    let budget = state.config.graph.land_rounds;
1621    let mut round = 0usize;
1622    // Counted apart from `round`: a rebase is not a fix, and a base that
1623    // moved is not the change's fault.
1624    let mut rebases = 0usize;
1625    let mut waited = Duration::ZERO;
1626    // Comment bodies the fixer has already been shown. A comment is
1627    // outstanding until it has been handed over once; after that it is a
1628    // recorded decision, not an open question, and re-feeding it would loop the
1629    // budget away on a comment the fixer already declined with an argument.
1630    let mut shown: BTreeSet<String> = BTreeSet::new();
1631
1632    // Marks the run resumable through exactly this function, not through a
1633    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
1634    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
1635    // status specifically to know a resumed run belongs back in `land`
1636    // rather than at a second `gh pr create`. Set on every entry - fresh or
1637    // resumed - because a resume that parked here again must keep reading
1638    // `Landing`, not whatever a first pass through `merge` left behind.
1639    state.status = RunStatus::Landing;
1640    state.event("land", format!("watching {pr_url}"));
1641    state.save()?;
1642
1643    loop {
1644        let seen = observe(&repo, pr_url).await?;
1645        let mut pr = seen.pr;
1646        pr.review_comments.retain(|c| !shown.contains(&c.body));
1647        state.pr = Some(crate::run::PrRecord {
1648            url: pr.url.clone(),
1649            number: pr.number,
1650            state: pr.state.as_str().to_owned(),
1651            checks: pr.checks.as_str().to_owned(),
1652            round,
1653            rounds: budget,
1654        });
1655        state.save()?;
1656
1657        match decide(&pr, round, budget, waited) {
1658            Step::Wait => {
1659                if waited >= WAIT_CEILING {
1660                    let why = format!(
1661                        "checks were still running after {} minutes",
1662                        WAIT_CEILING.as_secs() / 60
1663                    );
1664                    stop(state, &repo, &pr, &why).await?;
1665                    return Ok(pr);
1666                }
1667                waited += POLL;
1668                tokio::time::sleep(POLL).await;
1669            }
1670            Step::Done { merged } => {
1671                state.status = if merged {
1672                    RunStatus::Merged
1673                } else {
1674                    RunStatus::Ready
1675                };
1676                let detail = if merged {
1677                    format!("{} was merged", pr.url)
1678                } else {
1679                    format!("{} was closed without merging", pr.url)
1680                };
1681                state.merge = Some(MergeOutcome {
1682                    mode: MergeMode::Pr,
1683                    ok: merged,
1684                    detail: detail.clone(),
1685                });
1686                state.event("land", detail);
1687                state.save()?;
1688                return Ok(pr);
1689            }
1690            Step::Merge => {
1691                let subject = merge_subject(&seen.title, &state.instruction);
1692                // The owner sees the panel before the one irreversible step,
1693                // and an unanswered question is a hold: silence never merges.
1694                if state.config.graph.land_approval {
1695                    match approval_gate(state, &pr, &subject).await? {
1696                        ApprovalGate::Approved => {}
1697                        ApprovalGate::Held => {
1698                            stop(
1699                                state,
1700                                &repo,
1701                                &pr,
1702                                "the owner did not approve the merge (held or unanswered)",
1703                            )
1704                            .await?;
1705                            return Ok(pr);
1706                        }
1707                        // Filed (or still standing from an earlier visit) and
1708                        // not yet answered. Park here rather than wait: the
1709                        // question survives on disk, the daemon hands this
1710                        // run's slot to something else, and a later resume
1711                        // re-enters `land`, finds the same question, and
1712                        // either merges or stops depending on what it says
1713                        // by then.
1714                        ApprovalGate::Pending => {
1715                            state.parked = true;
1716                            state.event(
1717                                "land",
1718                                "parked awaiting merge approval - resumes once answered",
1719                            );
1720                            state.save()?;
1721                            return Ok(pr);
1722                        }
1723                    }
1724                }
1725                let argv = merge_argv(pr.number, &subject);
1726                let out = {
1727                    let merge_lock = repo_merge_lock(&repo);
1728                    let _merge_slot = merge_lock.lock().await;
1729                    gh(&repo, &argv).await?
1730                };
1731                if out.0 {
1732                    pr.state = PrLifecycle::Merged;
1733                    state.status = RunStatus::Merged;
1734                    state.merge = Some(MergeOutcome {
1735                        mode: MergeMode::Pr,
1736                        ok: true,
1737                        detail: format!("gh {}", argv.join(" ")),
1738                    });
1739                    // The last `state.pr` snapshot is whatever the poll before
1740                    // this merge observed - still `open` - and nothing below
1741                    // refreshes it from GitHub again, so the UI's round rail
1742                    // would otherwise keep animating a merged run forever.
1743                    if let Some(pr_record) = state.pr.as_mut() {
1744                        pr_record.state = pr.state.as_str().to_owned();
1745                    }
1746                    state.event("land", format!("merged {} as `{subject}`", pr.url));
1747                    state.save()?;
1748                    return Ok(pr);
1749                }
1750                let after = observe(&repo, pr_url).await.ok().map(|s| s.pr.state);
1751                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
1752                    pr.state = PrLifecycle::Merged;
1753                    state.status = RunStatus::Merged;
1754                    state.merge = Some(outcome);
1755                    if let Some(pr_record) = state.pr.as_mut() {
1756                        pr_record.state = pr.state.as_str().to_owned();
1757                    }
1758                    state.event("land", format!("merged {} as `{subject}`", pr.url));
1759                    state.save()?;
1760                    return Ok(pr);
1761                }
1762                stop(
1763                    state,
1764                    &repo,
1765                    &pr,
1766                    &format!("`gh pr merge` failed: {}", out.1),
1767                )
1768                .await?;
1769                return Ok(pr);
1770            }
1771            Step::Rebase => {
1772                // Bounded by the same budget as a fix, because a rebase that
1773                // keeps being needed means the base moves faster than this
1774                // run can land and a person should decide what to do. It
1775                // spends none of that budget: the change is not what is
1776                // wrong.
1777                if rebases >= budget {
1778                    let why = format!(
1779                        "the base moved under this branch {budget} time(s) and it still does \
1780                         not merge; rebasing again would only race it"
1781                    );
1782                    stop(state, &repo, &pr, &why).await?;
1783                    return Ok(pr);
1784                }
1785                rebases += 1;
1786                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
1787                    stop(
1788                        state,
1789                        &repo,
1790                        &pr,
1791                        "the pull request conflicts and this run has no winning branch to rebase",
1792                    )
1793                    .await?;
1794                    return Ok(pr);
1795                };
1796                let base = state.base_branch.clone();
1797                state.event(
1798                    "land",
1799                    format!("{} no longer merges; rebasing onto {base}", pr.url),
1800                );
1801                state.save()?;
1802
1803                // Onto the base as the *remote* has it: the local ref may be
1804                // behind, and rebasing onto a stale base produces a branch
1805                // that conflicts all over again.
1806                git::fetch(&repo, "origin", &base).await.ok();
1807                let scratch = state.dir().join("rebase");
1808                let onto = format!("origin/{base}");
1809                match git::rebase_branch_in_temp(&repo, &scratch, &branch, &onto).await {
1810                    Ok(None) => {
1811                        let pushed = {
1812                            let merge_lock = repo_merge_lock(&repo);
1813                            let _merge_slot = merge_lock.lock().await;
1814                            git::push_rewritten(&repo, "origin", &branch).await?
1815                        };
1816                        if !pushed.ok() {
1817                            let why = format!(
1818                                "rebased {branch} but could not push it: {}",
1819                                pushed.stderr.trim()
1820                            );
1821                            stop(state, &repo, &pr, &why).await?;
1822                            return Ok(pr);
1823                        }
1824                        state.event("land", format!("rebased {branch} onto {base}"));
1825                        state.save()?;
1826                        // The forge has to re-run its checks against the
1827                        // rebased head before anything else can be decided.
1828                        waited = Duration::ZERO;
1829                        tokio::time::sleep(POLL).await;
1830                    }
1831                    // A conflict is a decision, not a chore.
1832                    Ok(Some(conflict)) => {
1833                        let why = format!(
1834                            "{} conflicts with {base} and the rebase did not apply: {}",
1835                            pr.url,
1836                            conflict.chars().take(600).collect::<String>()
1837                        );
1838                        stop(state, &repo, &pr, &why).await?;
1839                        return Ok(pr);
1840                    }
1841                    Err(e) => {
1842                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
1843                        stop(state, &repo, &pr, &why).await?;
1844                        return Ok(pr);
1845                    }
1846                }
1847            }
1848            Step::GiveUp { reason } => {
1849                stop(state, &repo, &pr, &reason).await?;
1850                return Ok(pr);
1851            }
1852            Step::Fix { reason } => {
1853                round += 1;
1854                waited = Duration::ZERO;
1855                for c in &pr.review_comments {
1856                    shown.insert(c.body.clone());
1857                }
1858                state.event("land", format!("round {round}: {reason}"));
1859                state.save()?;
1860
1861                let logs = failing_logs(&repo, &seen.failing_urls).await;
1862                let was_red = pr.checks == Checks::Red;
1863                match fix_round(state, &pr, round, budget, &reason, &logs).await? {
1864                    Fixed::Committed => {}
1865                    Fixed::Declined if was_red => {
1866                        let why = format!(
1867                            "the fixer produced no commit while {} check(s) were failing \
1868                             ({}); stopping instead of looping on an unchanged tree",
1869                            pr.failing.len(),
1870                            pr.failing.join(", ")
1871                        );
1872                        stop(state, &repo, &pr, &why).await?;
1873                        return Ok(pr);
1874                    }
1875                    // Comment-driven round with no commit: the fixer read the
1876                    // comments and changed nothing, which is a decision it is
1877                    // allowed to make. The comments are recorded as shown, so
1878                    // the next observation sees a clean pull request.
1879                    Fixed::Declined => state.event(
1880                        "land",
1881                        format!("round {round}: fixer declined the comments, nothing committed"),
1882                    ),
1883                    Fixed::Failed(why) => {
1884                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
1885                        return Ok(pr);
1886                    }
1887                }
1888                state.save()?;
1889            }
1890        }
1891    }
1892}
1893
1894/// One observation, plus the two things [`PrState`] deliberately does not carry:
1895/// the title (needed for the squash subject) and where the failing checks'
1896/// logs live.
1897struct Seen {
1898    pr: PrState,
1899    title: String,
1900    failing_urls: Vec<(String, String)>,
1901}
1902
1903/// Read the pull request: `gh pr view` for the rollup and the top-level thread,
1904/// `gh api` for the inline review comments `gh pr view` does not report.
1905async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
1906    let view = gh(
1907        repo,
1908        &[
1909            "pr".to_owned(),
1910            "view".to_owned(),
1911            pr_url.to_owned(),
1912            "--json".to_owned(),
1913            "url,number,state,title,statusCheckRollup,reviews,comments,mergeStateStatus".to_owned(),
1914        ],
1915    )
1916    .await?;
1917    if !view.0 {
1918        bail!("gh pr view {pr_url}: {}", view.1);
1919    }
1920    let mut pr = parse_pr(&view.1)?;
1921    let raw: GhPr = serde_json::from_str(&view.1).context("re-read pull request json")?;
1922
1923    let inline = gh(
1924        repo,
1925        &[
1926            "api".to_owned(),
1927            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", pr.number),
1928        ],
1929    )
1930    .await?;
1931    if inline.0 {
1932        match parse_inline_comments(&inline.1) {
1933            Ok(mut comments) => pr.review_comments.append(&mut comments),
1934            // An unreadable inline thread must not end a landing: the rollup
1935            // and the top-level thread are still real signal.
1936            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
1937        }
1938    } else {
1939        tracing::warn!("gh api pulls/{}/comments: {}", pr.number, inline.1);
1940    }
1941
1942    let failing_urls = raw
1943        .status_check_rollup
1944        .iter()
1945        .filter(|c| c.verdict() == Verdict::Fail)
1946        .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
1947        .collect();
1948
1949    Ok(Seen {
1950        pr,
1951        title: raw.title,
1952        failing_urls,
1953    })
1954}
1955
1956/// What a fix round did.
1957enum Fixed {
1958    /// The fixer committed something.
1959    Committed,
1960    /// The fixer ran and chose to change nothing.
1961    Declined,
1962    /// The fixer could not run, or said nothing usable.
1963    Failed(String),
1964}
1965
1966/// Hand the failures and the comments to the fixer, then commit and push.
1967///
1968/// The fixer works in the winner's own worktree so its commits land on the
1969/// branch the pull request is built from, and it runs with `allow_write` for
1970/// the same reason.
1971async fn fix_round(
1972    state: &mut RunState,
1973    pr: &PrState,
1974    round: usize,
1975    budget: usize,
1976    reason: &str,
1977    logs: &str,
1978) -> Result<Fixed> {
1979    let winner = state
1980        .winner()
1981        .cloned()
1982        .context("landing needs a winning candidate; none is recorded on this run")?;
1983    let roles = state
1984        .config
1985        .resolve_roles()
1986        .context("resolve the roster for the fix round")?;
1987    // Same rule as the review loop: an explicitly configured fixer, otherwise
1988    // the winner's own author continuing its own conversation - the competition
1989    // is over, so its context is pure benefit.
1990    let (spec, seat_key): (AgentSpec, String) = match &roles.fixer {
1991        Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1992        _ => (
1993            state
1994                .config
1995                .agent(&winner.agent)
1996                .cloned()
1997                .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
1998            format!("impl-{}", winner.label),
1999        ),
2000    };
2001
2002    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
2003    let mut seat = seat_of(state, &seat_key, &spec.id);
2004    let artifacts = agent::artifacts_dir(&state.dir());
2005    let prompt = if state.config.cache_dir().is_some() {
2006        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
2007    } else {
2008        prompt
2009    };
2010    let out = agent::invoke(
2011        &spec,
2012        &mut seat,
2013        &Invocation {
2014            cwd: &winner.worktree,
2015            prompt: &prompt,
2016            timeout: Duration::from_secs(state.config.graph.timeout_fix),
2017            allow_write: true,
2018            sessions: state.config.graph.sessions,
2019            artifacts: &artifacts,
2020            stem: &format!("land-{round}"),
2021            run: &state.id,
2022            node: "land",
2023            cache_dir: state.config.cache_dir().as_deref(),
2024            attachments: &[],
2025        },
2026    )
2027    .await;
2028    state.seats.insert(seat.key.clone(), seat);
2029
2030    match out {
2031        Ok(o) if o.quota_exhausted() => {
2032            return Ok(Fixed::Failed(
2033                "rate limited (quota); the fixer could not run".to_owned(),
2034            ));
2035        }
2036        Ok(o) if !o.usable() => {
2037            return Ok(Fixed::Failed(format!(
2038                "the fixer produced nothing usable (exit {:?}, timed out: {})",
2039                o.exit_code, o.timed_out
2040            )));
2041        }
2042        Ok(_) => {}
2043        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
2044    }
2045
2046    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2047    // An agent that edited files but never committed would otherwise push
2048    // nothing and look like a refusal.
2049    if let Ok(r) = git::rescue_commit(
2050        &winner.worktree,
2051        &format!("magi: land round {round} fixes (uncommitted work)"),
2052    )
2053    .await
2054    {
2055        state.note_withheld("land", &r.withheld);
2056    }
2057    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2058    if after == before {
2059        return Ok(Fixed::Declined);
2060    }
2061
2062    let remote = state.config.merge.remote.clone();
2063    let push = git::push(&winner.worktree, &remote, &winner.branch).await?;
2064    if !push.ok() {
2065        return Ok(Fixed::Failed(format!(
2066            "pushing {} to {remote} failed: {}",
2067            winner.branch, push.stderr
2068        )));
2069    }
2070    state.event(
2071        "land",
2072        format!("round {round}: pushed a fix to {}", winner.branch),
2073    );
2074    Ok(Fixed::Committed)
2075}
2076
2077/// Fetch or create a seat, keeping its conversation across nodes.
2078fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
2079    if let Some(existing) = state.seats.get(key)
2080        && existing.agent == agent
2081    {
2082        return existing.clone();
2083    }
2084    let fresh = SeatState::new(key, agent, state.seed);
2085    state.seats.insert(key.to_owned(), fresh.clone());
2086    fresh
2087}
2088
2089/// What the fixer is told.
2090fn fix_prompt(
2091    state: &RunState,
2092    pr: &PrState,
2093    round: usize,
2094    budget: usize,
2095    reason: &str,
2096    logs: &str,
2097) -> String {
2098    let mut s = format!(
2099        "Your patch is open as a pull request and it is not landing. Land round \
2100         {round} of {budget}.\n\n\
2101         Pull request: {}\n\n\
2102         What is holding it: {reason}\n\n\
2103         # The task\n\n{}\n",
2104        pr.url, state.instruction
2105    );
2106
2107    if pr.failing.is_empty() {
2108        s.push_str("\n# Failing checks\n\n(none)\n");
2109    } else {
2110        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
2111        if logs.trim().is_empty() {
2112            s.push_str("\nNo log could be read; reproduce the failure locally.\n");
2113        } else {
2114            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
2115        }
2116    }
2117
2118    if pr.review_comments.is_empty() {
2119        s.push_str("\n# Review comments\n\n(none)\n");
2120    } else {
2121        s.push_str("\n# Review comments\n");
2122        for c in &pr.review_comments {
2123            let where_ = match (&c.path, c.line) {
2124                (Some(p), Some(l)) => format!(" ({p}:{l})"),
2125                (Some(p), None) => format!(" ({p})"),
2126                _ => String::new(),
2127            };
2128            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
2129        }
2130    }
2131
2132    s.push_str(
2133        "\n# Rules\n\n\
2134         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
2135            failing test; do not silence a lint with an allow attribute; do not \
2136            stretch a timeout to hide a race. If the check is right, the code is \
2137            wrong.\n\
2138         2. Change nothing the checks and the comments did not raise. A \
2139            drive-by refactor turns a one-line fix into a pull request that \
2140            needs reviewing again.\n\
2141         3. If a comment is wrong, say so with a checkable argument and change \
2142            nothing for it. A declined comment with a reason is a correct \
2143            outcome; a change made to appease a reviewer is not.\n\
2144         4. Commit in this worktree. magi pushes to the pull request's branch \
2145            for you; do not push, merge, or close anything yourself.\n\
2146         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
2147         # Output\n\n\
2148         Say what you changed and why, and what you declined and why.",
2149    );
2150
2151    let language = &state.config.graph.language;
2152    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
2153        let _ = write!(s, "\n\nWrite all prose in {language}.");
2154    }
2155    // After the language line, so the exception is the last word on it.
2156    s.push_str(&crate::prompt::github_english(language));
2157    if let Some(overlay) = state.config.prompts.overlay("fix") {
2158        let _ = write!(s, "\n\n{overlay}");
2159    }
2160    s
2161}
2162
2163/// Failing log tails, the way the operator collects them by hand:
2164/// `gh run view --log-failed`.
2165async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
2166    let mut out = String::new();
2167    for (name, url) in failing.iter().take(MAX_LOGS) {
2168        let args = match (job_of(url), run_of(url)) {
2169            (Some(job), _) => vec![
2170                "run".to_owned(),
2171                "view".to_owned(),
2172                "--log-failed".to_owned(),
2173                "--job".to_owned(),
2174                job,
2175            ],
2176            (None, Some(run)) => vec![
2177                "run".to_owned(),
2178                "view".to_owned(),
2179                run,
2180                "--log-failed".to_owned(),
2181            ],
2182            // Not a GitHub Actions check - an external status has no log here.
2183            (None, None) => continue,
2184        };
2185        let (ok, body) = match gh(repo, &args).await {
2186            Ok(v) => v,
2187            Err(e) => (false, format!("{e:#}")),
2188        };
2189        if !ok && body.trim().is_empty() {
2190            continue;
2191        }
2192        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
2193    }
2194    out
2195}
2196
2197/// Job id out of a check's `detailsUrl`
2198/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
2199fn job_of(details_url: &str) -> Option<String> {
2200    let after = details_url.split("/job/").nth(1)?;
2201    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2202    (!id.is_empty()).then_some(id)
2203}
2204
2205/// Workflow run id out of a check's `detailsUrl`.
2206fn run_of(details_url: &str) -> Option<String> {
2207    let after = details_url.split("/actions/runs/").nth(1)?;
2208    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
2209    (!id.is_empty()).then_some(id)
2210}
2211
2212/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
2213/// it lands on GitHub, not in front of the operator. Pure so a test can hold
2214/// it to that.
2215fn stop_comment(run_id: &str, why: &str) -> String {
2216    format!(
2217        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
2218         The branch is untouched and the run is `{run_id}`. Nothing was merged."
2219    )
2220}
2221
2222/// Leave the pull request open, say why on it, and mark the run blocked.
2223///
2224/// The comment is what makes an unattended stop actionable: the operator wakes
2225/// up to a pull request that explains itself rather than to a silent queue.
2226async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
2227    let body = stop_comment(&state.id, why);
2228    let posted = gh(
2229        repo,
2230        &[
2231            "pr".to_owned(),
2232            "comment".to_owned(),
2233            pr.number.to_string(),
2234            "--body".to_owned(),
2235            body,
2236        ],
2237    )
2238    .await;
2239    match posted {
2240        Ok((true, _)) => {}
2241        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
2242        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
2243    }
2244    state.status = RunStatus::Blocked;
2245    state.merge = Some(MergeOutcome {
2246        mode: MergeMode::Pr,
2247        ok: false,
2248        detail: why.to_owned(),
2249    });
2250    state.event("land", format!("stopped: {why}"));
2251    state.save()?;
2252    Ok(())
2253}
2254
2255/// Run `gh` in `repo`, returning success and the combined output.
2256///
2257/// Combined because `gh` reports a refused merge on stderr and the pull request
2258/// json on stdout, and both are evidence.
2259async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
2260    let out = tokio::process::Command::new("gh")
2261        .args(args)
2262        .current_dir(cwd)
2263        .quiet()
2264        .stdin(std::process::Stdio::null())
2265        .output()
2266        .await
2267        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
2268    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
2269    let err = String::from_utf8_lossy(&out.stderr);
2270    if body.trim().is_empty() {
2271        body = err.into_owned();
2272    } else if !err.trim().is_empty() {
2273        body.push_str(&err);
2274    }
2275    Ok((out.status.success(), body.trim().to_owned()))
2276}
2277
2278/// Verdict of one entry in the status rollup.
2279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2280enum Verdict {
2281    Pass,
2282    Fail,
2283    Pending,
2284    Unknown,
2285}
2286
2287#[derive(Debug, Deserialize)]
2288#[serde(rename_all = "camelCase")]
2289struct GhPr {
2290    #[serde(default)]
2291    url: String,
2292    #[serde(default)]
2293    number: u64,
2294    #[serde(default)]
2295    state: String,
2296    #[serde(default)]
2297    title: String,
2298    #[serde(default)]
2299    status_check_rollup: Vec<GhCheck>,
2300    /// GitHub's own verdict on whether the pull request can be merged.
2301    ///
2302    /// Worth asking for because it is the only place the *required* check set
2303    /// is applied: the rollup lists every check equally, so a repository that
2304    /// deliberately does not require `coverage` still looks red here. See
2305    /// [`Blocking`].
2306    #[serde(default)]
2307    merge_state_status: String,
2308    #[serde(default)]
2309    reviews: Vec<GhReview>,
2310    #[serde(default)]
2311    comments: Vec<GhComment>,
2312}
2313
2314/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
2315/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
2316/// status API, which is how CodeRabbit reports - has `context`/`state` and no
2317/// conclusion at all.
2318#[derive(Debug, Deserialize)]
2319#[serde(rename_all = "camelCase")]
2320struct GhCheck {
2321    #[serde(default)]
2322    name: Option<String>,
2323    #[serde(default)]
2324    context: Option<String>,
2325    #[serde(default)]
2326    status: Option<String>,
2327    #[serde(default)]
2328    conclusion: Option<String>,
2329    #[serde(default)]
2330    state: Option<String>,
2331    #[serde(default)]
2332    details_url: Option<String>,
2333    #[serde(default)]
2334    target_url: Option<String>,
2335}
2336
2337impl GhCheck {
2338    /// Name to show a human and hand to the fixer.
2339    fn label(&self) -> String {
2340        self.name
2341            .clone()
2342            .or_else(|| self.context.clone())
2343            .unwrap_or_else(|| "(unnamed check)".to_owned())
2344    }
2345
2346    /// Where this check's logs live, when it has any.
2347    fn url(&self) -> Option<&str> {
2348        self.details_url
2349            .as_deref()
2350            .or(self.target_url.as_deref())
2351            .filter(|u| !u.is_empty())
2352    }
2353
2354    /// Did it pass?
2355    ///
2356    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
2357    /// skips release and bot pull requests by design, and a skip that blocked
2358    /// landing would block exactly the pull requests that need no review.
2359    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
2360    /// merging over one is merging over a check that never ran.
2361    fn verdict(&self) -> Verdict {
2362        if let Some(status) = self.status.as_deref() {
2363            if !status.eq_ignore_ascii_case("COMPLETED") {
2364                return Verdict::Pending;
2365            }
2366        }
2367        let outcome = self
2368            .conclusion
2369            .as_deref()
2370            .or(self.state.as_deref())
2371            .unwrap_or("");
2372        match outcome.to_ascii_uppercase().as_str() {
2373            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
2374            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
2375            | "ACTION_REQUIRED" => Verdict::Fail,
2376            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
2377                Verdict::Pending
2378            }
2379            _ => Verdict::Unknown,
2380        }
2381    }
2382}
2383
2384#[derive(Debug, Deserialize)]
2385struct GhAuthor {
2386    #[serde(default)]
2387    login: String,
2388}
2389
2390#[derive(Debug, Deserialize)]
2391struct GhReview {
2392    #[serde(default)]
2393    author: GhAuthor,
2394    #[serde(default)]
2395    body: String,
2396}
2397
2398#[derive(Debug, Deserialize)]
2399struct GhComment {
2400    #[serde(default)]
2401    author: GhAuthor,
2402    #[serde(default)]
2403    body: String,
2404}
2405
2406#[derive(Debug, Deserialize)]
2407struct GhUser {
2408    #[serde(default)]
2409    login: String,
2410}
2411
2412#[derive(Debug, Deserialize)]
2413struct GhInline {
2414    #[serde(default)]
2415    user: GhUser,
2416    #[serde(default)]
2417    path: Option<String>,
2418    #[serde(default)]
2419    line: Option<u64>,
2420    #[serde(default)]
2421    body: String,
2422}
2423
2424impl Default for GhAuthor {
2425    fn default() -> Self {
2426        Self {
2427            login: "(unknown)".to_owned(),
2428        }
2429    }
2430}
2431
2432impl Default for GhUser {
2433    fn default() -> Self {
2434        Self {
2435            login: "(unknown)".to_owned(),
2436        }
2437    }
2438}
2439
2440#[cfg(test)]
2441mod tests {
2442    use super::*;
2443    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
2444
2445    /// 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.
2446    const GREEN_OPEN: &str = r####"{
2447  "url": "https://github.com/yukimemi/magi/pull/10",
2448  "number": 10,
2449  "state": "OPEN",
2450  "mergeStateStatus": "CLEAN",
2451  "statusCheckRollup": [
2452    {
2453      "__typename": "CheckRun",
2454      "conclusion": "SKIPPED",
2455      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
2456      "name": "review",
2457      "status": "COMPLETED",
2458      "workflowName": "claude-review"
2459    },
2460    {
2461      "__typename": "CheckRun",
2462      "conclusion": "SUCCESS",
2463      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
2464      "name": "check (ubuntu-latest)",
2465      "status": "COMPLETED",
2466      "workflowName": "CI"
2467    },
2468    {
2469      "__typename": "CheckRun",
2470      "conclusion": "SUCCESS",
2471      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
2472      "name": "rustfmt",
2473      "status": "COMPLETED",
2474      "workflowName": "CI"
2475    },
2476    {
2477      "__typename": "StatusContext",
2478      "context": "CodeRabbit",
2479      "state": "SUCCESS",
2480      "targetUrl": ""
2481    }
2482  ],
2483  "reviews": [],
2484  "comments": [
2485    {
2486      "author": {
2487        "login": "coderabbitai"
2488      },
2489      "authorAssociation": "NONE",
2490      "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"
2491    }
2492  ]
2493}"####;
2494
2495    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
2496    const RED_OPEN: &str = r####"{
2497  "url": "https://github.com/yukimemi/magi/pull/9",
2498  "number": 9,
2499  "state": "OPEN",
2500  "mergeStateStatus": "UNSTABLE",
2501  "statusCheckRollup": [
2502    {
2503      "__typename": "CheckRun",
2504      "conclusion": "SUCCESS",
2505      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2506      "name": "check (ubuntu-latest)",
2507      "status": "COMPLETED",
2508      "workflowName": "CI"
2509    },
2510    {
2511      "__typename": "CheckRun",
2512      "conclusion": "SUCCESS",
2513      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2514      "name": "rustfmt",
2515      "status": "COMPLETED",
2516      "workflowName": "CI"
2517    },
2518    {
2519      "__typename": "CheckRun",
2520      "conclusion": "FAILURE",
2521      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2522      "name": "editorconfig",
2523      "status": "COMPLETED",
2524      "workflowName": "CI"
2525    },
2526    {
2527      "__typename": "StatusContext",
2528      "context": "CodeRabbit",
2529      "state": "SUCCESS",
2530      "targetUrl": ""
2531    }
2532  ],
2533  "reviews": [],
2534  "comments": [
2535    {
2536      "author": {
2537        "login": "coderabbitai"
2538      },
2539      "authorAssociation": "NONE",
2540      "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"
2541    }
2542  ]
2543}"####;
2544
2545    /// 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.
2546    const PENDING_OPEN: &str = r####"{
2547  "url": "https://github.com/yukimemi/magi/pull/9",
2548  "number": 9,
2549  "state": "OPEN",
2550  "statusCheckRollup": [
2551    {
2552      "__typename": "CheckRun",
2553      "conclusion": "SUCCESS",
2554      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
2555      "name": "check (ubuntu-latest)",
2556      "status": "COMPLETED",
2557      "workflowName": "CI"
2558    },
2559    {
2560      "__typename": "CheckRun",
2561      "conclusion": "SUCCESS",
2562      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
2563      "name": "rustfmt",
2564      "status": "COMPLETED",
2565      "workflowName": "CI"
2566    },
2567    {
2568      "__typename": "CheckRun",
2569      "conclusion": null,
2570      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
2571      "name": "editorconfig",
2572      "status": "IN_PROGRESS",
2573      "workflowName": "CI"
2574    },
2575    {
2576      "__typename": "StatusContext",
2577      "context": "CodeRabbit",
2578      "state": "SUCCESS",
2579      "targetUrl": ""
2580    }
2581  ],
2582  "reviews": [],
2583  "comments": []
2584}"####;
2585
2586    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
2587    const MERGED: &str = r####"{
2588  "url": "https://github.com/yukimemi/magi/pull/16",
2589  "number": 16,
2590  "state": "MERGED",
2591  "statusCheckRollup": [
2592    {
2593      "__typename": "CheckRun",
2594      "conclusion": "SUCCESS",
2595      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
2596      "name": "check (ubuntu-latest)",
2597      "status": "COMPLETED",
2598      "workflowName": "CI"
2599    },
2600    {
2601      "__typename": "CheckRun",
2602      "conclusion": "SUCCESS",
2603      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
2604      "name": "review",
2605      "status": "COMPLETED",
2606      "workflowName": "claude-review"
2607    }
2608  ],
2609  "reviews": [],
2610  "comments": []
2611}"####;
2612
2613    /// 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.
2614    const REVIEWED_OPEN: &str = r####"{
2615  "url": "https://github.com/yukimemi/magi/pull/12",
2616  "number": 12,
2617  "state": "OPEN",
2618  "statusCheckRollup": [
2619    {
2620      "__typename": "CheckRun",
2621      "conclusion": "SUCCESS",
2622      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
2623      "name": "check (ubuntu-latest)",
2624      "status": "COMPLETED",
2625      "workflowName": "CI"
2626    },
2627    {
2628      "__typename": "CheckRun",
2629      "conclusion": "SUCCESS",
2630      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
2631      "name": "review",
2632      "status": "COMPLETED",
2633      "workflowName": "claude-review"
2634    }
2635  ],
2636  "reviews": [
2637    {
2638      "author": {
2639        "login": "claude"
2640      },
2641      "state": "COMMENTED",
2642      "body": ""
2643    }
2644  ],
2645  "comments": [
2646    {
2647      "author": {
2648        "login": "coderabbitai"
2649      },
2650      "authorAssociation": "NONE",
2651      "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"
2652    },
2653    {
2654      "author": {
2655        "login": "claude"
2656      },
2657      "authorAssociation": "NONE",
2658      "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"
2659    }
2660  ]
2661}"####;
2662
2663    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
2664    const INLINE: &str = r####"[
2665  {
2666    "user": {
2667      "login": "claude[bot]"
2668    },
2669    "path": "src/graph.rs",
2670    "line": 231,
2671    "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"
2672  }
2673]"####;
2674
2675    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
2676    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
2677<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
2678
2679> [!IMPORTANT]
2680> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
2681> 
2682> This repository does not receive automatic reviews because it has fewer than 10 stars.
2683> 
2684> <details>
2685> <summary>⚙️ Run configuration</summary>
2686> 
2687> **Configuration used**: defaults
2688> 
2689> **Review profile**: CHILL
2690> 
2691> **Plan**: Team
2692> 
2693> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
2694> 
2695> </details>
2696
2697<!-- end of auto-generated comment: skip review by coderabbit.ai -->
2698
2699<!-- tips_start -->
2700
2701---
2702
2703Thanks 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.
2704
2705<details>
2706<summary>❤️ Share</summary>
2707
2708- [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"####;
2709
2710    /// 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.
2711    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
2712
2713---
2714### Reviewing PR #16
2715
2716- [x] Read AGENTS.md conventions
2717- [x] Review `src/daemon.rs` changes
2718- [x] Review `src/main.rs` changes (new `doctor` reporting)
2719- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
2720- [x] Check test coverage for new behavior
2721- [x] Run verification commands (blocked — see note)
2722- [x] Post findings"####;
2723
2724    /// The same job's real comment on pull request #12 once it had something to say.
2725    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
2726
2727---
2728### Review: `magi review <branch>` — cheap-half-only graph
2729
2730Read 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.
2731
2732**Correctness**
2733
2734- 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"####;
2735
2736    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
2737        PrState {
2738            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
2739            number: 16,
2740            state: PrLifecycle::Open,
2741            checks,
2742            // These tests are about red-means-fix, so a red here is one the
2743            // forge gates on. Without saying so they would assert the new
2744            // "merge past a check nobody requires" path by accident.
2745            blocking: if matches!(checks, Checks::Red) {
2746                Blocking::Yes
2747            } else {
2748                Blocking::No
2749            },
2750            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
2751            review_comments: (0..comments)
2752                .map(|i| ReviewComment {
2753                    author: "coderabbitai".to_owned(),
2754                    path: Some("src/graph.rs".to_owned()),
2755                    line: Some(231),
2756                    body: format!("finding {i}"),
2757                })
2758                .collect(),
2759        }
2760    }
2761
2762    #[test]
2763    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
2764        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
2765        assert_eq!(state.number, 10);
2766        assert_eq!(state.state, PrLifecycle::Open);
2767        assert_eq!(state.checks, Checks::Green);
2768        assert!(state.failing.is_empty());
2769        assert!(
2770            state.review_comments.is_empty(),
2771            "the only comment is CodeRabbit's trigger notice: {:?}",
2772            state.review_comments
2773        );
2774        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
2775    }
2776
2777    #[test]
2778    fn a_failing_check_parses_as_red_and_is_named() {
2779        let state = parse_pr(RED_OPEN).expect("red fixture parses");
2780        assert_eq!(state.checks, Checks::Red);
2781        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
2782        // The captured payload says `UNSTABLE` - mergeable, with a check
2783        // nobody requires red - which is exactly the shape that had to be
2784        // merged by hand. Asserted separately, in
2785        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
2786        // test is about is that a red check is *named*, so the reason a fixer
2787        // is handed says which one; so it asks the blocking question here.
2788        let mut blocking = state.clone();
2789        blocking.blocking = Blocking::Yes;
2790        match decide(&blocking, 0, 4, Duration::ZERO) {
2791            Step::Fix { reason } => {
2792                assert!(reason.contains("editorconfig"), "reason: {reason}");
2793                assert!(reason.contains("failing"), "reason: {reason}");
2794            }
2795            other => panic!("expected a fix round, got {other:?}"),
2796        }
2797    }
2798
2799    #[test]
2800    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
2801        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
2802        assert_eq!(state.checks, Checks::Pending);
2803        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
2804    }
2805
2806    #[test]
2807    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
2808        let state = parse_pr(MERGED).expect("merged fixture parses");
2809        assert_eq!(state.state, PrLifecycle::Merged);
2810        assert_eq!(
2811            decide(&state, 0, 4, Duration::ZERO),
2812            Step::Done { merged: true }
2813        );
2814    }
2815
2816    #[test]
2817    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
2818        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
2819        assert_eq!(state.checks, Checks::Green);
2820        let authors: Vec<&str> = state
2821            .review_comments
2822            .iter()
2823            .map(|c| c.author.as_str())
2824            .collect();
2825        assert_eq!(
2826            authors,
2827            vec!["claude"],
2828            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
2829        );
2830        match decide(&state, 0, 4, Duration::ZERO) {
2831            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
2832            other => panic!("expected a fix round, got {other:?}"),
2833        }
2834    }
2835
2836    #[test]
2837    fn inline_review_comments_keep_their_file_and_line() {
2838        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
2839        assert_eq!(comments.len(), 1);
2840        assert_eq!(comments[0].author, "claude[bot]");
2841        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
2842        assert_eq!(comments[0].line, Some(231));
2843        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
2844    }
2845
2846    #[test]
2847    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
2848        assert!(
2849            is_noise(CODERABBIT_TRIGGER),
2850            "CodeRabbit's trigger notice declares itself not a review"
2851        );
2852        assert!(
2853            is_noise(CLAUDE_CHECKLIST),
2854            "a progress checklist asks for nothing"
2855        );
2856        assert!(
2857            !is_noise(CLAUDE_FINDING),
2858            "a review that names a bug is input, not noise"
2859        );
2860
2861        let mut clean = pr(Checks::Green, &[], 0);
2862        clean.review_comments.push(ReviewComment {
2863            author: "coderabbitai".to_owned(),
2864            path: None,
2865            line: None,
2866            body: CODERABBIT_TRIGGER.to_owned(),
2867        });
2868        clean.review_comments.retain(|c| !is_noise(&c.body));
2869        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
2870
2871        let mut found = pr(Checks::Green, &[], 0);
2872        found.review_comments.push(ReviewComment {
2873            author: "claude".to_owned(),
2874            path: None,
2875            line: None,
2876            body: CLAUDE_FINDING.to_owned(),
2877        });
2878        found.review_comments.retain(|c| !is_noise(&c.body));
2879        assert!(matches!(
2880            decide(&found, 0, 4, Duration::ZERO),
2881            Step::Fix { .. }
2882        ));
2883    }
2884
2885    #[test]
2886    fn the_policy_table_holds_for_every_combination_that_matters() {
2887        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
2888            (
2889                "pending checks are waited for, even on the last round",
2890                pr(Checks::Pending, &[], 0),
2891                4,
2892                4,
2893                Duration::ZERO,
2894                Step::Wait,
2895            ),
2896            (
2897                "red checks are fixed",
2898                pr(Checks::Red, &["editorconfig"], 0),
2899                0,
2900                4,
2901                Duration::ZERO,
2902                Step::Fix {
2903                    reason: "1 check(s) failing: editorconfig".to_owned(),
2904                },
2905            ),
2906            (
2907                "green with comments is fixed, not merged",
2908                pr(Checks::Green, &[], 2),
2909                1,
2910                4,
2911                Duration::ZERO,
2912                Step::Fix {
2913                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
2914                        .to_owned(),
2915                },
2916            ),
2917            (
2918                "green and clean merges",
2919                pr(Checks::Green, &[], 0),
2920                3,
2921                4,
2922                Duration::ZERO,
2923                Step::Merge,
2924            ),
2925            (
2926                "an unreadable rollup is waited on while the grace lasts",
2927                pr(Checks::Unknown, &[], 0),
2928                0,
2929                4,
2930                Duration::ZERO,
2931                Step::Wait,
2932            ),
2933            (
2934                "an unreadable rollup is never merged once the grace is spent",
2935                pr(Checks::Unknown, &[], 0),
2936                0,
2937                4,
2938                CHECKS_GRACE,
2939                Step::GiveUp {
2940                    reason: "no check status is readable on the pull request after 3 minute(s); \
2941                             refusing to merge on a guess"
2942                        .to_owned(),
2943                },
2944            ),
2945        ];
2946        for (what, state, round, budget, waited, want) in cases {
2947            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
2948        }
2949    }
2950
2951    #[test]
2952    fn the_forge_verdict_survives_the_round_trip_from_gh() {
2953        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
2954        // requested but never parsed is the kind of thing that looks wired up
2955        // and answers `Unsaid` forever.
2956        let green = parse_pr(GREEN_OPEN).expect("parse");
2957        assert_eq!(green.blocking, Blocking::No);
2958        let red = parse_pr(RED_OPEN).expect("parse");
2959        assert_eq!(
2960            red.blocking,
2961            Blocking::No,
2962            "`UNSTABLE` is mergeable: the red check is one nobody requires"
2963        );
2964        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
2965        // A payload from an older `gh` has no such field at all.
2966        let quiet =
2967            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
2968        assert_eq!(quiet.blocking, Blocking::Unsaid);
2969    }
2970
2971    #[test]
2972    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
2973        // Pull request 37's only red check was `editorconfig`, failing
2974        // because the action could not fetch its own binary after
2975        // editorconfig-checker v4 renamed its release assets. The repository
2976        // does not require it. magi answered by asking a fixer to repair a
2977        // change that was fine, and the pull request had to be merged by hand.
2978        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
2979        nonblocking.blocking = Blocking::No;
2980        assert_eq!(
2981            decide(&nonblocking, 0, 4, Duration::ZERO),
2982            Step::Merge,
2983            "the forge says nothing is in the way, so nothing is"
2984        );
2985
2986        // The same red, gated on: that is a fix round, as before.
2987        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
2988        blocking.blocking = Blocking::Yes;
2989        assert!(matches!(
2990            decide(&blocking, 0, 4, Duration::ZERO),
2991            Step::Fix { .. }
2992        ));
2993
2994        // A review comment still outranks green-enough: a non-required red
2995        // must not become a way to merge past an unanswered reviewer.
2996        let mut commented = pr(Checks::Red, &["coverage"], 1);
2997        commented.blocking = Blocking::No;
2998        assert!(matches!(
2999            decide(&commented, 0, 4, Duration::ZERO),
3000            Step::Fix { .. }
3001        ));
3002
3003        // And silence from the forge is not consent.
3004        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
3005        unsaid.blocking = Blocking::Unsaid;
3006        assert!(matches!(
3007            decide(&unsaid, 0, 4, Duration::ZERO),
3008            Step::Fix { .. }
3009        ));
3010    }
3011
3012    #[test]
3013    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
3014        // Pull requests 35 and 37 were both rebased by hand: a competition
3015        // that runs for two hours against a repository merging pull requests
3016        // all day conflicts on the way in, and that is arithmetic rather
3017        // than a defect in the change.
3018        let mut conflicted = pr(Checks::Green, &[], 0);
3019        conflicted.blocking = Blocking::Conflict;
3020        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
3021
3022        // Decided before the checks, and even with the rounds spent: every
3023        // check on a branch that cannot land is an answer about a state that
3024        // cannot land, and a conflict is not the change's fault.
3025        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
3026        red.blocking = Blocking::Conflict;
3027        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
3028
3029        // The lifecycle still wins over everything, conflict included.
3030        let mut merged = pr(Checks::Red, &[], 0);
3031        merged.blocking = Blocking::Conflict;
3032        merged.state = PrLifecycle::Merged;
3033        assert_eq!(
3034            decide(&merged, 0, 4, Duration::ZERO),
3035            Step::Done { merged: true }
3036        );
3037    }
3038
3039    #[test]
3040    fn the_forge_verdict_is_read_off_merge_state_status() {
3041        // The spellings that mean "mergeable". `UNSTABLE` is the one that
3042        // matters: mergeable, with a non-required check red or still running.
3043        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
3044            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
3045            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
3046        }
3047        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
3048        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
3049        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
3050        // An older `gh`, or a token without the scope, says nothing - and
3051        // refusing to guess is the rule everywhere else in this module.
3052        for quiet in ["", "UNKNOWN"] {
3053            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
3054            assert!(Blocking::of(quiet).stops_a_merge());
3055        }
3056    }
3057
3058    #[test]
3059    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
3060        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
3061        // The exact stderr from run ec12, in a jj-colocated repository.
3062        let jj = "could not determine current branch: failed to run git: not on any branch";
3063
3064        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
3065            .expect("the forge says merged, so it merged");
3066        assert!(landed.ok);
3067        assert!(
3068            landed.detail.contains("but the pull request is merged"),
3069            "the record must not read as a clean success: {}",
3070            landed.detail
3071        );
3072        assert!(
3073            landed.detail.contains("not on any branch"),
3074            "and it must keep what the command actually said: {}",
3075            landed.detail
3076        );
3077
3078        // A pull request still open means the merge really failed.
3079        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
3080        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
3081        // And an unreadable answer is not evidence of success.
3082        assert!(merged_after_all(&argv, jj, None).is_none());
3083    }
3084
3085    #[test]
3086    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
3087        let mut state = pr(Checks::Red, &["editorconfig"], 3);
3088        state.state = PrLifecycle::Closed;
3089        assert_eq!(
3090            decide(&state, 0, 4, Duration::ZERO),
3091            Step::Done { merged: false },
3092            "a human closing the pull request ends the loop, whatever CI says"
3093        );
3094    }
3095
3096    #[test]
3097    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
3098        let red = decide(
3099            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
3100            4,
3101            4,
3102            Duration::ZERO,
3103        );
3104        match red {
3105            Step::GiveUp { reason } => {
3106                assert!(reason.contains("editorconfig"), "reason: {reason}");
3107                assert!(reason.contains("test (macos)"), "reason: {reason}");
3108                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
3109            }
3110            other => panic!("expected a give-up, got {other:?}"),
3111        }
3112
3113        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
3114        match commented {
3115            Step::GiveUp { reason } => {
3116                assert!(reason.contains("unresolved"), "reason: {reason}");
3117                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
3118            }
3119            other => panic!("expected a give-up, got {other:?}"),
3120        }
3121    }
3122
3123    #[test]
3124    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
3125        let candidate_commit = "magi: candidate A (uncommitted work)";
3126        let subject = merge_subject(candidate_commit, "add retries to the uploader");
3127        let argv = merge_argv(16, &subject);
3128
3129        assert!(argv.contains(&"--squash".to_owned()));
3130        assert!(argv.contains(&"--delete-branch".to_owned()));
3131        assert!(argv.contains(&"--subject".to_owned()));
3132        assert_eq!(
3133            argv.last().map(String::as_str),
3134            Some("add retries to the uploader"),
3135            "the subject must not be the candidate commit message"
3136        );
3137        assert_ne!(subject, candidate_commit);
3138    }
3139
3140    #[test]
3141    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
3142        assert_eq!(
3143            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
3144            "feat: a queue, an unattended loop, and a phone UI"
3145        );
3146        assert_eq!(
3147            merge_subject("", "# port the retry logic\n\ndetails"),
3148            "port the retry logic",
3149            "an empty title falls back to the task's first line, heading marks stripped"
3150        );
3151    }
3152
3153    #[test]
3154    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
3155        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
3156        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
3157        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
3158        assert_eq!(job_of("https://coderabbit.ai/status"), None);
3159        assert_eq!(run_of(""), None);
3160    }
3161
3162    #[test]
3163    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
3164        let mut out = Vec::new();
3165        push_if_outstanding(
3166            &mut out,
3167            ReviewComment {
3168                author: "yukimemi".to_owned(),
3169                path: None,
3170                line: None,
3171                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
3172            },
3173        );
3174        assert!(out.is_empty());
3175    }
3176
3177    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
3178    /// falls back to the repository - which keeps these tests free of a
3179    /// worktree, a `git` invocation and a network.
3180    fn run_state() -> RunState {
3181        RunState::new(
3182            std::path::PathBuf::from("/repo/magi"),
3183            "main".to_owned(),
3184            "abcdef1234".to_owned(),
3185            "add retries to the uploader".to_owned(),
3186            crate::config::Config::default(),
3187        )
3188    }
3189
3190    fn green_pr() -> PrState {
3191        PrState {
3192            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
3193            number: 42,
3194            state: PrLifecycle::Open,
3195            checks: Checks::Green,
3196            // The forge sees nothing in the way unless a test says otherwise.
3197            blocking: Blocking::No,
3198            failing: Vec::new(),
3199            review_comments: vec![ReviewComment {
3200                author: "coderabbitai".to_owned(),
3201                path: Some("src/land.rs".to_owned()),
3202                line: Some(212),
3203                body: "this branch never checks the exit code".to_owned(),
3204            }],
3205        }
3206    }
3207
3208    #[test]
3209    fn github_facing_land_text_is_english_whatever_the_language() {
3210        let mut state = run_state();
3211        state.config.graph.language = "ja".to_owned();
3212        let comment = stop_comment(&state.id, "checks are still red");
3213        assert!(comment.is_ascii(), "{comment}");
3214        assert!(comment.starts_with(MARKER));
3215
3216        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3217        let ja_at = p.find("Write all prose in ja").unwrap();
3218        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
3219        assert!(ja_at < rule_at, "{p}");
3220        assert!(p.contains("stays in Japanese"), "{p}");
3221
3222        state.config.graph.language = "en".to_owned();
3223        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
3224        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
3225        assert!(!p.contains("does not apply"), "{p}");
3226    }
3227
3228    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
3229
3230    fn panel() -> String {
3231        approval_panel(
3232            &run_state(),
3233            &green_pr(),
3234            NUMSTAT,
3235            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
3236            &[
3237                "land: ask before merging".to_owned(),
3238                "land: colour the diff".to_owned(),
3239            ],
3240            "feat: merge approval from the phone",
3241        )
3242    }
3243
3244    #[test]
3245    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
3246        let html = panel();
3247        for needle in [
3248            "42",
3249            "main",
3250            "src/land.rs",
3251            "src/web.rs",
3252            "assets/logo.png",
3253            "feat: merge approval from the phone",
3254            "land: ask before merging",
3255            "land: colour the diff",
3256            "coderabbitai",
3257            "this branch never checks the exit code",
3258            "green",
3259        ] {
3260            assert!(html.contains(needle), "the panel must state `{needle}`");
3261        }
3262    }
3263
3264    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
3265    /// resolves to it.
3266    fn winning_candidate(summary: &str) -> Candidate {
3267        Candidate {
3268            index: 0,
3269            label: 'A',
3270            agent: "opus".to_owned(),
3271            branch: "magi/x/A".to_owned(),
3272            worktree: PathBuf::from("/wt/A"),
3273            summary: summary.to_owned(),
3274            stat: String::new(),
3275            files: 1,
3276            commits: 1,
3277            empty: false,
3278            failed: None,
3279            verified_noop: None,
3280            duration_ms: 0,
3281            folded: false,
3282        }
3283    }
3284
3285    fn uncontested_tally() -> Tally {
3286        Tally {
3287            first_choice: BTreeMap::from([('A', 1)]),
3288            borda: BTreeMap::new(),
3289            winner: 'A',
3290            rankings: 1,
3291            unanimous_initial: true,
3292            deliberated: false,
3293            changed_votes: 0,
3294            unanimous_final: true,
3295            tie_break: None,
3296            judges: 1,
3297            present: 1,
3298            quorum: 1,
3299            met_quorum: true,
3300            uncontested: None,
3301        }
3302    }
3303
3304    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
3305        ReviewRecord {
3306            attempts: 0,
3307            reviewer,
3308            agent: agent.to_owned(),
3309            summary: summary.to_owned(),
3310            findings: Vec::new(),
3311            vote: None,
3312            failed: None,
3313            duration_ms: 0,
3314        }
3315    }
3316
3317    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
3318        let answered = reviews.len();
3319        ReviewRound {
3320            round,
3321            head: "abc1234".to_owned(),
3322            verified_head: None,
3323            verified_at: None,
3324            reviews,
3325            e2e: Vec::new(),
3326            verify_retried: false,
3327            e2e_deferred: false,
3328            e2e_defer_reason: None,
3329            fix: None,
3330            blocking: 0,
3331            answered,
3332            expected: answered,
3333            clean: true,
3334            progressed: false,
3335            vote_split: false,
3336            reconsideration: Vec::new(),
3337            verdict: None,
3338        }
3339    }
3340
3341    #[test]
3342    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
3343        let en = panel();
3344        assert!(en.contains("Task"), "{en}");
3345        assert!(en.contains("add retries to the uploader"), "{en}");
3346
3347        let mut state = run_state();
3348        state.config.graph.language = "ja".to_owned();
3349        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3350        assert!(ja.contains("タスク"), "{ja}");
3351        assert!(
3352            ja.contains("add retries to the uploader"),
3353            "the task itself is not translated: {ja}"
3354        );
3355    }
3356
3357    #[test]
3358    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
3359        // `run_state()` has no candidates, no tally and no reviews - exactly
3360        // the shape a run has before anything has judged or reviewed it, and
3361        // the panel must not print an empty box for either.
3362        let html = panel();
3363        assert!(!html.contains("What changed"), "{html}");
3364        assert!(!html.contains("Review verdict"), "{html}");
3365    }
3366
3367    #[test]
3368    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
3369        let mut state = run_state();
3370        state.candidates = vec![winning_candidate("")];
3371        state.tally = Some(uncontested_tally());
3372        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3373        assert!(
3374            !html.contains("What changed"),
3375            "an empty summary must not render an empty box: {html}"
3376        );
3377    }
3378
3379    #[test]
3380    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
3381        let mut state = run_state();
3382        state.candidates = vec![winning_candidate(
3383            "Added a retry loop around the uploader PUT call.",
3384        )];
3385        state.tally = Some(uncontested_tally());
3386        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3387        assert!(en.contains("What changed"), "{en}");
3388        assert!(
3389            en.contains("Added a retry loop around the uploader PUT call."),
3390            "{en}"
3391        );
3392
3393        state.config.graph.language = "ja".to_owned();
3394        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3395        assert!(ja.contains("変更内容"), "{ja}");
3396        assert!(
3397            ja.contains("Added a retry loop around the uploader PUT call."),
3398            "{ja}"
3399        );
3400    }
3401
3402    #[test]
3403    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
3404        let mut state = run_state();
3405        state.reviews = vec![
3406            review_round(
3407                1,
3408                vec![review_record(1, "alpha", "found a race, sent back")],
3409            ),
3410            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
3411        ];
3412        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3413        assert!(en.contains("Review verdict"), "{en}");
3414        assert!(en.contains("race is fixed, clean"), "{en}");
3415        assert!(
3416            !en.contains("found a race, sent back"),
3417            "only the round that actually cleared the merge should show: {en}"
3418        );
3419
3420        state.config.graph.language = "ja".to_owned();
3421        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3422        assert!(ja.contains("レビューの結論"), "{ja}");
3423        assert!(ja.contains("レビュアー"), "{ja}");
3424        assert!(ja.contains("race is fixed, clean"), "{ja}");
3425    }
3426
3427    /// The `incomplete_review = "warn"` policy (see
3428    /// `graph::Runner::review_loop`) can push a `clean` round to
3429    /// `state.reviews` while one seat's own record still has `failed: Some`
3430    /// and an empty `summary` - a seat that never answered, not one that
3431    /// answered with nothing to say.
3432    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
3433        ReviewRecord {
3434            attempts: 0,
3435            reviewer,
3436            agent: agent.to_owned(),
3437            summary: String::new(),
3438            findings: Vec::new(),
3439            vote: None,
3440            failed: Some(reason.to_owned()),
3441            duration_ms: 0,
3442        }
3443    }
3444
3445    #[test]
3446    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
3447        let mut state = run_state();
3448        state.reviews = vec![review_round(
3449            1,
3450            vec![
3451                review_record(1, "alpha", "clean, nothing to add"),
3452                unanswered_review_record(2, "beta", "timed out"),
3453            ],
3454        )];
3455        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3456        assert!(en.contains("clean, nothing to add"), "{en}");
3457        assert!(
3458            en.contains("produced no answer: timed out"),
3459            "a seat that never answered must say so, not render a blank box: {en}"
3460        );
3461        assert!(
3462            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
3463            "no reviewer box may be left empty: {en}"
3464        );
3465
3466        state.config.graph.language = "ja".to_owned();
3467        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
3468        assert!(ja.contains("回答なし: timed out"), "{ja}");
3469    }
3470
3471    #[test]
3472    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
3473        let html = panel();
3474        assert!(!html.contains("<script"), "no script survives the csp");
3475        assert!(!html.contains("<form"), "form-action is 'none'");
3476        let pr = green_pr();
3477        assert_eq!(
3478            html.matches("http").count(),
3479            html.matches(pr.url.as_str()).count(),
3480            "the only http url in the panel is the pull request's own link"
3481        );
3482    }
3483
3484    #[test]
3485    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
3486        let html = panel();
3487        assert!(
3488            html.contains(">+</span>"),
3489            "an added line carries a `+` in the gutter, not only a background"
3490        );
3491        assert!(
3492            html.contains(">-</span>"),
3493            "a removed line carries a `-` in the gutter, not only a background"
3494        );
3495        assert!(
3496            html.contains(">new line</span>"),
3497            "the marker is moved to the gutter, so the body is printed once without it"
3498        );
3499    }
3500
3501    #[test]
3502    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
3503        let total = DIFF_MAX_LINES + 100;
3504        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
3505        let html = approval_panel(
3506            &run_state(),
3507            &green_pr(),
3508            NUMSTAT,
3509            &diff,
3510            &[],
3511            "feat: something long",
3512        );
3513        assert!(
3514            html.contains(&format!("100 of {total} diff lines omitted")),
3515            "the note must say exactly how much was cut"
3516        );
3517        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
3518        assert!(
3519            !html.contains(&format!("line {DIFF_MAX_LINES}")),
3520            "nothing past the threshold is rendered"
3521        );
3522        assert!(
3523            html.contains("/repo/magi"),
3524            "the note says where the rest is"
3525        );
3526    }
3527
3528    #[test]
3529    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
3530        let html = approval_panel(
3531            &run_state(),
3532            &green_pr(),
3533            "1\t2\tsrc/<b>&\"x\"'.rs",
3534            "",
3535            &[],
3536            "subject",
3537        );
3538        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
3539        assert!(
3540            !html.contains("<b>"),
3541            "an agent-influenced path must never become markup"
3542        );
3543    }
3544
3545    #[tokio::test]
3546    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
3547        let a = std::path::PathBuf::from("/repo/a");
3548        let b = std::path::PathBuf::from("/repo/b");
3549
3550        let held = repo_merge_lock(&a).lock_owned().await;
3551
3552        // A second, concurrent land run against the *same* repository must
3553        // wait - `try_lock` fails while `held` is alive.
3554        assert!(
3555            repo_merge_lock(&a).try_lock().is_err(),
3556            "a second merge into the same repository must not proceed concurrently"
3557        );
3558
3559        // A run against a *different* repository must not be blocked by it -
3560        // this is what keeps a slow rebase or `gh pr merge` in one
3561        // repository from also stalling a land-approval resume in another.
3562        assert!(
3563            repo_merge_lock(&b).try_lock().is_ok(),
3564            "a different repository's merge lock must be independent"
3565        );
3566
3567        drop(held);
3568        assert!(
3569            repo_merge_lock(&a).try_lock().is_ok(),
3570            "the lock is released once the holder is done"
3571        );
3572    }
3573
3574    #[test]
3575    fn only_the_merge_choice_merges_and_silence_holds() {
3576        let table = [
3577            (None, Approval::Hold),
3578            (Some("merge"), Approval::Merge),
3579            (Some(" merge\n"), Approval::Merge),
3580            (Some("hold"), Approval::Hold),
3581            (Some(""), Approval::Hold),
3582            (Some("yes"), Approval::Hold),
3583        ];
3584        for (answer, want) in table {
3585            assert_eq!(
3586                approval(answer),
3587                want,
3588                "answer {answer:?} must resolve to {want:?}"
3589            );
3590        }
3591    }
3592
3593    #[tokio::test]
3594    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
3595        crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3596        let mut state = run_state();
3597        state.config.graph.land_approval = true;
3598        let pr = green_pr();
3599
3600        let gate = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3601        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
3602        assert!(
3603            !state.parked,
3604            "approval_gate itself never sets `parked`; only its caller does"
3605        );
3606
3607        let store = ask::Questions::open();
3608        let filed: Vec<_> = store
3609            .list()
3610            .into_iter()
3611            .filter(|q| q.run == state.id)
3612            .collect();
3613        assert_eq!(filed.len(), 1, "exactly one question is filed");
3614        assert_eq!(filed[0].node, APPROVAL_NODE);
3615        assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
3616        assert!(filed[0].status.open());
3617
3618        // A second visit - standing in for a resumed run whose slot the
3619        // daemon handed to something else while nobody had answered - must
3620        // find the same question rather than filing a second one.
3621        let again = approval_gate(&mut state, &pr, "feat: x").await.unwrap();
3622        assert_eq!(again, ApprovalGate::Pending);
3623        let still_one = store
3624            .list()
3625            .into_iter()
3626            .filter(|q| q.run == state.id)
3627            .count();
3628        assert_eq!(
3629            still_one, 1,
3630            "asking twice must not double-file the question"
3631        );
3632    }
3633
3634    #[tokio::test]
3635    async fn approving_the_existing_question_is_read_back_as_approved() {
3636        crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3637        let mut state = run_state();
3638        state.config.graph.land_approval = true;
3639        let pr = green_pr();
3640        assert_eq!(
3641            approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3642            ApprovalGate::Pending
3643        );
3644
3645        let store = ask::Questions::open();
3646        let mut q = store
3647            .list()
3648            .into_iter()
3649            .find(|q| q.run == state.id)
3650            .expect("filed above");
3651        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
3652        store.put(&mut q).unwrap();
3653
3654        assert_eq!(
3655            approval_gate(&mut state, &pr, "feat: x").await.unwrap(),
3656            ApprovalGate::Approved
3657        );
3658    }
3659
3660    #[tokio::test]
3661    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
3662        crate::run::set_home(std::env::temp_dir().join("magi-land-approval-test-home"));
3663        let store = ask::Questions::open();
3664
3665        let mut held_state = run_state();
3666        held_state.config.graph.land_approval = true;
3667        let pr = green_pr();
3668        approval_gate(&mut held_state, &pr, "feat: x")
3669            .await
3670            .unwrap();
3671        let mut q = store
3672            .list()
3673            .into_iter()
3674            .find(|q| q.run == held_state.id)
3675            .expect("filed above");
3676        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
3677        store.put(&mut q).unwrap();
3678        assert_eq!(
3679            approval_gate(&mut held_state, &pr, "feat: x")
3680                .await
3681                .unwrap(),
3682            ApprovalGate::Held
3683        );
3684
3685        let mut abandoned_state = run_state();
3686        abandoned_state.config.graph.land_approval = true;
3687        approval_gate(&mut abandoned_state, &pr, "feat: x")
3688            .await
3689            .unwrap();
3690        let mut q = store
3691            .list()
3692            .into_iter()
3693            .find(|q| q.run == abandoned_state.id)
3694            .expect("filed above");
3695        q.abandon("no answer within the timeout");
3696        store.put(&mut q).unwrap();
3697        assert_eq!(
3698            approval_gate(&mut abandoned_state, &pr, "feat: x")
3699                .await
3700                .unwrap(),
3701            ApprovalGate::Held,
3702            "silence must never merge"
3703        );
3704    }
3705
3706    #[test]
3707    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
3708        let rows = parse_numstat(NUMSTAT);
3709        assert_eq!(
3710            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
3711            ["src/web.rs", "src/land.rs", "assets/logo.png"]
3712        );
3713        assert_eq!(rows[2].added, None, "a binary file has no line counts");
3714    }
3715    #[test]
3716    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
3717        // Reported from a real run: the merge question arrived in English on a
3718        // repository with `language = "ja"`. magi's own strings have to follow
3719        // that setting too - "it is a literal in Rust" is not an answer.
3720        let mut state = run_state();
3721        state.config.graph.language = "ja".to_owned();
3722        let pr = green_pr();
3723        let commits = ["c1".to_owned()];
3724
3725        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3726        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
3727        assert!(ja.contains("squash されるコミット"), "{ja}");
3728        assert!(ja.contains("レビューコメント"), "{ja}");
3729        assert!(ja.contains("差分"), "{ja}");
3730        assert!(
3731            !ja.contains("Commits being squashed"),
3732            "no English left over"
3733        );
3734
3735        let w = words("ja");
3736        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
3737        assert!(
3738            w.approval_detail("http://x/1", "main", "feat: x")
3739                .contains("パネル")
3740        );
3741
3742        // The evidence itself is language-neutral and must survive either way.
3743        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
3744        assert!(ja.contains("feat: x"), "nor is the merge subject");
3745
3746        // English stays the default, and a language magi cannot check falls
3747        // back to it rather than shipping a guess.
3748        state.config.graph.language = "en".to_owned();
3749        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
3750        assert!(en.contains("Commits being squashed"), "{en}");
3751        assert_eq!(words("Klingon").html_lang, "en");
3752    }
3753
3754    /// A `gh pr list` result naming exactly one pull request whose base and
3755    /// merge time both fit the run is exactly the case
3756    /// [`find_external_merge`] exists to act on.
3757    #[test]
3758    fn pick_merged_pr_picks_the_unique_match() {
3759        let json = r#"[
3760            {"url": "https://github.com/o/r/pull/42", "number": 42,
3761             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
3762        ]"#;
3763        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3764        let found = pick_merged_pr(json, "main", created_at)
3765            .expect("valid json")
3766            .expect("one unambiguous match");
3767        assert_eq!(found.url, "https://github.com/o/r/pull/42");
3768        assert_eq!(found.number, 42);
3769    }
3770
3771    /// Two candidates surviving the filter is exactly as uninformative as
3772    /// zero — a branch name can be reused across runs — so neither is
3773    /// preferred over the other and nothing is recorded automatically.
3774    #[test]
3775    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
3776        let json = r#"[
3777            {"url": "https://github.com/o/r/pull/42", "number": 42,
3778             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
3779            {"url": "https://github.com/o/r/pull/43", "number": 43,
3780             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
3781        ]"#;
3782        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3783        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3784    }
3785
3786    /// A pull request that targets a different base branch cannot be this
3787    /// run's, whatever its head branch is named — a reused branch name from
3788    /// an unrelated task must not be recorded as this run's merge.
3789    #[test]
3790    fn pick_merged_pr_ignores_a_different_base_branch() {
3791        let json = r#"[
3792            {"url": "https://github.com/o/r/pull/42", "number": 42,
3793             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
3794        ]"#;
3795        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3796        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3797    }
3798
3799    /// A pull request merged before this run was even created cannot be this
3800    /// run's winner, no matter how its head branch is spelled.
3801    #[test]
3802    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
3803        let json = r#"[
3804            {"url": "https://github.com/o/r/pull/42", "number": 42,
3805             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
3806        ]"#;
3807        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
3808        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
3809    }
3810
3811    /// No winner decided yet means there is no branch to ask GitHub about at
3812    /// all — `find_external_merge` must return `None` without ever spawning
3813    /// `gh`, which this proves by never providing a real repository to spawn
3814    /// it in.
3815    #[tokio::test]
3816    async fn find_external_merge_returns_none_without_a_winner() {
3817        let state = RunState::new(
3818            PathBuf::from("/no/such/repo"),
3819            "main".to_owned(),
3820            "0000000000000000000000000000000000000000".to_owned(),
3821            "irrelevant".to_owned(),
3822            crate::config::Config::default(),
3823        );
3824        assert_eq!(find_external_merge(&state).await.unwrap(), None);
3825    }
3826}