Skip to main content

magi/
land.rs

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