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