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