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