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::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// No check will ever report (`[release] mode = "local"` on a repository
347    /// whose Actions never run). Waiting out [`CHECKS_GRACE`] or reading
348    /// `unknown` as a refusal would stall forever, so the checks are read as
349    /// absent and the pull request is ready as soon as it merges cleanly.
350    Absent,
351}
352
353/// [`decide`] with the CI expectation made explicit. `Expected` is exactly
354/// [`decide`]; `Absent` reads the absence of CI as the reason to proceed, and
355/// still stops for a conflict (the base moved), which a rebase cures and no
356/// check state does.
357pub fn decide_with(
358    pr: &PrState,
359    round: usize,
360    budget: usize,
361    waited: Duration,
362    ci: CiExpectation,
363) -> Step {
364    match pr.state {
365        PrLifecycle::Merged => return Step::Done { merged: true },
366        PrLifecycle::Closed => return Step::Done { merged: false },
367        PrLifecycle::Open => {}
368    }
369
370    // Before the checks: every check on a branch that cannot land is an
371    // answer about a state that cannot land.
372    if pr.blocking == Blocking::Conflict {
373        return Step::Rebase;
374    }
375
376    if ci == CiExpectation::Absent {
377        return Step::Merge;
378    }
379
380    let spent = round >= budget;
381    match pr.checks {
382        Checks::Pending => Step::Wait,
383        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384        Checks::Unknown => Step::GiveUp {
385            reason: format!(
386                "no check status is readable on the pull request after {} minute(s); \
387                 refusing to merge on a guess",
388                CHECKS_GRACE.as_secs() / 60
389            ),
390        },
391        // Red, but the forge says it does not stand in the way: the failing
392        // checks are ones this repository chose not to require. Spending a fix
393        // round on them asks an agent to repair something nobody is gating on
394        // - and pull request 37's only red check was an *action* that could
395        // not fetch its own binary. Merge, and name them so the record is
396        // honest about what was red when it landed.
397        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398        Checks::Red => {
399            let what = format!(
400                "{} check(s) failing: {}",
401                pr.failing.len(),
402                pr.failing.join(", ")
403            );
404            if spent {
405                Step::GiveUp {
406                    reason: format!("{what} — still red after {budget} fix round(s)"),
407                }
408            } else {
409                Step::Fix { reason: what }
410            }
411        }
412        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413        Checks::Green => {
414            let what = format!(
415                "checks are green but {} review comment(s) are unresolved: {}",
416                pr.review_comments.len(),
417                authors(&pr.review_comments)
418            );
419            if spent {
420                Step::GiveUp {
421                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422                }
423            } else {
424                Step::Fix { reason: what }
425            }
426        }
427    }
428}
429
430/// Distinct comment authors, in the order they first appear.
431fn authors(comments: &[ReviewComment]) -> String {
432    let mut seen: Vec<&str> = Vec::new();
433    for c in comments {
434        if !seen.contains(&c.author.as_str()) {
435            seen.push(&c.author);
436        }
437    }
438    seen.join(", ")
439}
440
441/// The argv magi merges with, minus the program name.
442///
443/// `--subject` is the point of this function existing: see the module docs.
444pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445    vec![
446        "pr".to_owned(),
447        "merge".to_owned(),
448        number.to_string(),
449        "--squash".to_owned(),
450        "--delete-branch".to_owned(),
451        "--subject".to_owned(),
452        subject.to_owned(),
453    ]
454}
455
456/// [`merge_argv`] pinned to the commit the decision was made about.
457///
458/// `--match-head-commit` makes the forge refuse the merge if the branch moved
459/// after it was observed, so a push landing between the look and the merge is
460/// never merged unseen.
461pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462    let mut argv = merge_argv(number, subject);
463    argv.push("--match-head-commit".to_owned());
464    argv.push(head.to_owned());
465    argv
466}
467
468/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
469/// an older build may still have one standing on the forge, so the disarm
470/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
471/// armed merge never outlives the commit that was approved for it.
472pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473    ["pr", "merge", &number.to_string(), "--disable-auto"]
474        .map(str::to_owned)
475        .to_vec()
476}
477
478/// May the direct merge go ahead, judged from a fresh read?
479///
480/// The pull request must still be open on the approved head, the checks must
481/// have been read for that very head, and the policy must still say merge.
482/// Pure, so the head guard is asserted without a forge.
483fn direct_merge_is_safe(
484    fresh: Option<&Seen>,
485    approved_head: &str,
486    shown: &BTreeSet<String>,
487    round: usize,
488    budget: usize,
489    waited: Duration,
490) -> bool {
491    let Some(fresh) = fresh else {
492        return false;
493    };
494    if fresh.pr.state != PrLifecycle::Open {
495        return false;
496    }
497    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498        return false;
499    };
500    if !bound.eq_ignore_ascii_case(approved_head) {
501        return false;
502    }
503    let mut pr = fresh.pr.clone();
504    pr.review_comments.retain(|c| !shown.contains(&c.body));
505    decide(&pr, round, budget, waited) == Step::Merge
506}
507
508/// What GitHub is still waiting for, for the stop reason when an armed merge
509/// does not happen in time. No I/O.
510///
511/// Required checks that are pending or failing are named. A check whose
512/// required-ness could not be read is never assumed optional: every unsettled
513/// check is listed and the reason says so.
514fn waiting_on(
515    merge_state: &str,
516    contexts: &[CheckInfo],
517    required_set: Option<&BTreeSet<String>>,
518) -> String {
519    let state = if merge_state.is_empty() {
520        "unknown"
521    } else {
522        merge_state
523    };
524    let tag = |c: &CheckInfo| match c.verdict {
525        Verdict::Fail => "failed",
526        _ => "pending",
527    };
528    let unsettled: Vec<&CheckInfo> = contexts
529        .iter()
530        .filter(|c| c.verdict != Verdict::Pass)
531        .collect();
532    let required: Vec<String> = unsettled
533        .iter()
534        .filter(|c| c.required == Some(true))
535        .map(|c| format!("{} ({})", c.label, tag(c)))
536        .collect();
537    let unknown: Vec<String> = unsettled
538        .iter()
539        .filter(|c| c.required.is_none())
540        .map(|c| format!("{} ({})", c.label, tag(c)))
541        .collect();
542    // Required contexts the rollup never listed: nothing reported them, so no
543    // pending/failing entry exists to name. Matched case-insensitively against
544    // the labels as the rollup spells them, and no further.
545    let never: Vec<&str> = required_set
546        .map(|set| {
547            set.iter()
548                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549                .map(String::as_str)
550                .collect()
551        })
552        .unwrap_or_default();
553    let mut out = format!("merge state: {state}");
554    if !never.is_empty() {
555        let _ = write!(
556            out,
557            "; required checks never reported: {}",
558            never.join(", ")
559        );
560    }
561    if !required.is_empty() {
562        let _ = write!(
563            out,
564            "; required checks not passing: {}",
565            required.join(", ")
566        );
567    }
568    if !unknown.is_empty() {
569        let _ = write!(
570            out,
571            "; whether these are required could not be read, so they may be: {}",
572            unknown.join(", ")
573        );
574    }
575    if required.is_empty() && unknown.is_empty() && never.is_empty() {
576        if required_set.is_some() {
577            out.push_str(
578                "; no required check is pending, failing or unreported, so GitHub is probably \
579                 waiting for a review or another branch rule",
580            );
581        } else {
582            out.push_str(
583                "; the required check list could not be read, so a required check that was \
584                 never reported cannot be ruled out",
585            );
586        }
587    }
588    out
589}
590
591/// The squash subject to merge under.
592///
593/// The pull request title, unless it is empty or is a candidate branch's commit
594/// subject that leaked into the title - in which case the task's own first line
595/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
596/// reader nothing about what landed.
597pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598    let title = pr_title.trim();
599    if !title.is_empty() && !title.starts_with("magi: candidate") {
600        return title.to_owned();
601    }
602    let first = instruction
603        .lines()
604        .map(str::trim)
605        .find(|l| !l.is_empty())
606        .unwrap_or("magi: land the winning candidate");
607    first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610/// The choice that lets the merge happen, verbatim as the owner taps it.
611pub const APPROVE: &str = "merge";
612
613/// The choice that leaves the pull request open.
614pub const HOLD: &str = "hold";
615
616/// A bounded repair followed by a new review and a fresh owner decision.
617pub const REPAIR: &str = "fix findings and re-review";
618
619/// Independent of the contested handoff's AND condition: either open Major
620/// findings or rejecting final votes qualify once review handed off.
621pub fn repair_available(state: &RunState) -> bool {
622    state.reviews.last().is_some_and(|r| {
623        state.open_findings().iter().any(|f| f.severity.blocks())
624            || r.final_votes()
625                .iter()
626                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627    })
628}
629
630/// Shared choices for web, CLI and consultation questions.
631pub fn approval_choices(state: &RunState) -> Vec<String> {
632    let mut choices = vec![APPROVE.to_owned(), HOLD.to_owned()];
633    if repair_available(state) {
634        choices.push(REPAIR.to_owned());
635    }
636    choices
637}
638
639/// Upgrade an unanswered legacy two-choice question without changing its id,
640/// panel, head binding or any already recorded owner decision.
641pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
642    if q.node == APPROVAL_NODE
643        && q.status.open()
644        && repair_available(state)
645        && state
646            .land_approval
647            .as_ref()
648            .is_some_and(|a| a.question == q.id)
649        && !q.choices.iter().any(|c| c == REPAIR)
650    {
651        q.choices.push(REPAIR.to_owned());
652        q.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
653        true
654    } else {
655        false
656    }
657}
658
659/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
660/// the whole message carries a hedge, a condition, a negation, a question or a
661/// retraction. Used by `deputy::destructive`.
662///
663/// The whole message is read, not just the quote's sentence: a condition or a
664/// second thought in another sentence ("Merge it. Only if CI passes.") makes
665/// the approval conditional all the same. Doubt anywhere is `false`.
666pub fn unhedged(message: &str, quote: &str) -> bool {
667    let quote = quote.trim();
668    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
669}
670
671/// Hedging, conditional, negated or interrogative wording. ASCII words are
672/// matched as whole words so `note` is not `not`. A bare negation or stop word
673/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
674fn hedged(text: &str) -> bool {
675    const WORDS: &[&str] = &[
676        "maybe",
677        "probably",
678        "perhaps",
679        "might",
680        "if",
681        "unless",
682        "not",
683        "no",
684        "nope",
685        "stop",
686        "dont",
687        "abort",
688        "revert",
689        "undo",
690        "never",
691        "wait",
692        "hold",
693        "cancel",
694        "but",
695        "think",
696        "guess",
697        "suppose",
698        "unsure",
699        "yet",
700        "except",
701        "only",
702        "cannot",
703        "should",
704        "once",
705        "provided",
706        "providing",
707        "when",
708        "whenever",
709        "after",
710        "until",
711        "before",
712        "assuming",
713        "given",
714        "while",
715        "whether",
716        "depending",
717        "pending",
718    ];
719    const JA: &[&str] = &[
720        "かも",
721        "たぶん",
722        "多分",
723        "なら",
724        "たら",
725        "ちょっと待",
726        "しないで",
727        "しない",
728        "保留",
729        "まだ",
730        "ただし",
731        "やめ",
732        "いや",
733        "止め",
734        "だめ",
735        "ダメ",
736        "じゃない",
737        "ではない",
738        "ですか",
739        "かな",
740        "でしょう",
741        "思う",
742        "でも",
743        "けど",
744        "ただ",
745        "次第",
746        "場合",
747        "限り",
748        "条件",
749        "とき",
750        "まで",
751        "後で",
752    ];
753    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
754        return true;
755    }
756    text.to_lowercase()
757        .replace('\u{2019}', "'")
758        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
759        .filter(|w| !w.is_empty())
760        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
761}
762
763/// Wording that takes back what the rest of the message said. Bare negation
764/// and stop words are `hedged`'s whole-word list, not substrings here.
765fn retracts(message: &str) -> bool {
766    let lower = message.to_lowercase();
767    [
768        "やっぱ",
769        "待って",
770        "撤回",
771        "never mind",
772        "actually",
773        "on second thought",
774    ]
775    .iter()
776    .any(|w| lower.contains(w))
777        || lower
778            .split(|c: char| !c.is_ascii_alphabetic())
779            .any(|w| w == "wait")
780}
781
782/// Graph node recorded on the approval question.
783///
784/// The phone keys its high-stakes card off this rather than off the choice
785/// strings, so renaming a button cannot silently downgrade the card that
786/// guards the one irreversible action magi takes.
787pub const APPROVAL_NODE: &str = "land-approval";
788
789/// Unified diff lines carried in the panel before it is truncated.
790///
791/// Four hundred: the panel is read on a 390px phone, where a diff line often
792/// wraps to two rows, so this is already a few thousand rows of scrolling -
793/// past that nobody is reading, and the bytes still count against the panel's
794/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
795/// cut and which worktree holds the whole patch.
796pub const DIFF_MAX_LINES: usize = 400;
797
798/// What the owner's answer to the approval question means.
799#[derive(Debug, Clone, Copy, PartialEq, Eq)]
800pub enum Approval {
801    /// The owner said [`APPROVE`]. Merge.
802    Merge,
803    /// Repair the existing PR, then ask again; never approval to merge.
804    Repair,
805    /// Anything else, including silence. Leave the pull request open.
806    Hold,
807}
808
809/// Read the owner's answer, where `None` is an unanswered question.
810///
811/// Silence is a hold. A timed-out question means the owner never saw it or
812/// never decided, and defaulting an irreversible merge to "yes" would make this
813/// gate worse than no gate at all: it would merge unattended while claiming to
814/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
815/// function does not recognise holds too.
816pub fn approval(answer: Option<&str>) -> Approval {
817    match answer {
818        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
819        Some(a) if a.trim() == REPAIR => Approval::Repair,
820        _ => Approval::Hold,
821    }
822}
823
824/// What [`approval_gate`] found on one check of the owner's merge decision.
825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826enum ApprovalGate {
827    /// The owner said [`APPROVE`]. Merge.
828    Approved,
829    /// The owner explicitly requested repair, without authorizing a merge.
830    Repair,
831    /// The owner said anything else, the question timed out, or it was
832    /// closed with no decision recorded.
833    Held,
834    /// Filed and still waiting - the caller parks rather than blocking on it.
835    Pending,
836}
837
838/// Escape text for HTML, including both quote characters.
839///
840/// Every string in the panel is agent-influenced: a branch name, a file path, a
841/// commit subject, a review comment. The sandboxed frame stops such text from
842/// *running*, but it does not stop a `<` from ending the document early or a
843/// `"` from ending an attribute and inventing a new one - the panel would then
844/// render a lie, or not render at all. Both quotes are escaped because the same
845/// function is used inside attributes, where remembering which quote style the
846/// caller used is one mistake away from an injected attribute.
847fn esc(s: &str) -> String {
848    let mut out = String::with_capacity(s.len());
849    for c in s.chars() {
850        match c {
851            '&' => out.push_str("&amp;"),
852            '<' => out.push_str("&lt;"),
853            '>' => out.push_str("&gt;"),
854            '"' => out.push_str("&quot;"),
855            '\'' => out.push_str("&#39;"),
856            _ => out.push(c),
857        }
858    }
859    out
860}
861
862/// One row of the diffstat table.
863#[derive(Debug, Clone, PartialEq, Eq)]
864struct StatRow {
865    path: String,
866    /// `None` for a binary file, which `git` reports as `-`.
867    added: Option<u64>,
868    removed: Option<u64>,
869}
870
871impl StatRow {
872    /// Lines touched, for sorting. A binary file counts as zero rather than as
873    /// unknown, which puts it at the bottom where it needs no attention.
874    fn churn(&self) -> u64 {
875        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
876    }
877}
878
879/// Parse `git diff --numstat` into rows, biggest churn first.
880///
881/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
882/// terminal width, so counting its characters would print fabricated numbers in
883/// the one table an operator approves an irreversible action from.
884fn parse_numstat(numstat: &str) -> Vec<StatRow> {
885    let mut rows: Vec<StatRow> = numstat
886        .lines()
887        .filter_map(|line| {
888            let mut parts = line.splitn(3, '\t');
889            let added = parts.next()?.trim();
890            let removed = parts.next()?.trim();
891            let path = parts.next()?.trim();
892            if path.is_empty() {
893                return None;
894            }
895            Some(StatRow {
896                path: path.to_owned(),
897                added: added.parse().ok(),
898                removed: removed.parse().ok(),
899            })
900        })
901        .collect();
902    // Path breaks the tie so the same change always renders the same table; an
903    // operator comparing two panels should not see rows shuffle.
904    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
905    rows
906}
907
908/// How one diff line is shown: a gutter character, a style, and the body to
909/// print - which is the line minus its marker, so the marker appears exactly
910/// once, in the gutter.
911///
912/// The gutter is why this exists at all. The operator may be colour blind, or
913/// reading in sunlight with the screen dimmed, so an added line is never
914/// distinguished by its background alone: `+` and `-` sit in a fixed column,
915/// the same mark they already read in a terminal.
916fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
917    if line.starts_with("+++") || line.starts_with("---") {
918        (" ", "color:#57606a;font-weight:600", line)
919    } else if let Some(body) = line.strip_prefix('+') {
920        ("+", "background:#e6ffec;color:#0a3622", body)
921    } else if let Some(body) = line.strip_prefix('-') {
922        ("-", "background:#ffebe9;color:#5c1a17", body)
923    } else if line.starts_with("@@") {
924        ("~", "background:#eef2ff;color:#3730a3", line)
925    } else if let Some(body) = line.strip_prefix(' ') {
926        (" ", "", body)
927    } else {
928        (" ", "color:#57606a;font-weight:600", line)
929    }
930}
931
932/// The handful of words the approval panel says in its own voice.
933///
934/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
935/// the operator asked why the merge question spoke English on a repository
936/// configured for Japanese, and "because that string is a literal in Rust" is
937/// not an answer. Only the languages magi can actually check are translated;
938/// anything else falls back to English rather than shipping a guess, and that
939/// fallback is deliberate.
940struct Words {
941    html_lang: &'static str,
942    task: &'static str,
943    what_changed: &'static str,
944    review_verdict: &'static str,
945    reviewer: &'static str,
946    reviewer_no_answer: &'static str,
947    checks: &'static str,
948    nothing_failing: &'static str,
949    files_changed: &'static str,
950    commits: &'static str,
951    no_commits: &'static str,
952    comments: &'static str,
953    no_comments: &'static str,
954    diff: &'static str,
955    truncated: &'static str,
956    lands_as: &'static str,
957}
958
959const EN: Words = Words {
960    html_lang: "en",
961    task: "Task",
962    what_changed: "What changed",
963    review_verdict: "Review verdict",
964    reviewer: "Reviewer",
965    reviewer_no_answer: "produced no answer",
966    checks: "Checks",
967    nothing_failing: "Nothing failing.",
968    files_changed: "file(s) changed",
969    commits: "Commits being squashed",
970    no_commits: "No commit subjects could be read from the branch.",
971    comments: "Review comments",
972    no_comments: "Nothing outstanding at this observation.",
973    diff: "Diff",
974    truncated: "Truncated",
975    lands_as: "They land as one commit titled",
976};
977
978const JA: Words = Words {
979    html_lang: "ja",
980    task: "タスク",
981    what_changed: "変更内容",
982    review_verdict: "レビューの結論",
983    reviewer: "レビュアー",
984    reviewer_no_answer: "回答なし",
985    checks: "チェック",
986    nothing_failing: "失敗しているものはありません。",
987    files_changed: "ファイル変更",
988    commits: "squash されるコミット",
989    no_commits: "ブランチからコミット件名を読めませんでした。",
990    comments: "レビューコメント",
991    no_comments: "この時点で未対応のものはありません。",
992    diff: "差分",
993    truncated: "省略",
994    lands_as: "これらは次の件名の1コミットとして入ります:",
995};
996
997impl Words {
998    /// The clause after the merge subject. Split out because word order moves:
999    /// Japanese puts the subject before the verb, so a shared template with a
1000    /// hole in the middle would read as machine translation.
1001    fn lands_as_tail(&self) -> &'static str {
1002        if self.html_lang == "ja" {
1003            "。この件名も承認の対象です。"
1004        } else {
1005            ", which you are approving too."
1006        }
1007    }
1008
1009    /// The question's own one-line summary, which is what a phone shows first.
1010    fn approval_summary(&self, number: u64, subject: &str) -> String {
1011        if self.html_lang == "ja" {
1012            format!("プルリクエスト #{number} をマージ: {subject}")
1013        } else {
1014            format!("merge pull request #{number}: {subject}")
1015        }
1016    }
1017
1018    /// The body under the summary, above the panel.
1019    fn approval_detail(
1020        &self,
1021        url: &str,
1022        base: &str,
1023        subject: &str,
1024        contested: Option<&ContestedHandoff>,
1025    ) -> String {
1026        let body = if self.html_lang == "ja" {
1027            format!(
1028                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1029                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1030                 下のパネルにあります。"
1031            )
1032        } else {
1033            format!(
1034                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1035                 The panel holds the diffstat, the patch and the commits being squashed."
1036            )
1037        };
1038        match contested {
1039            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1040            None => body,
1041        }
1042    }
1043
1044    /// Why this question exists although merge approvals are off: the open
1045    /// blocking findings and who rejected. Short enough for a phone.
1046    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1047        const SHOWN: usize = 5;
1048        const TITLE_CHARS: usize = 100;
1049        let ja = self.html_lang == "ja";
1050        let mut out = if ja {
1051            format!(
1052                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1053                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1054            )
1055        } else {
1056            format!(
1057                "{url} is held for approval although merge approvals are off: the \
1058                 review ended with blocking findings still open and a reviewer \
1059                 voting reject.\n"
1060            )
1061        };
1062        for f in c.findings.iter().take(SHOWN) {
1063            let at = match (&f.file, f.line) {
1064                (Some(file), Some(line)) => format!("{file}:{line}"),
1065                (Some(file), None) => file.clone(),
1066                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1067            };
1068            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1069            let _ = writeln!(out, "- {} {:?} {at}: {title}", f.id, f.severity);
1070        }
1071        if c.findings.len() > SHOWN {
1072            let more = c.findings.len() - SHOWN;
1073            let _ = writeln!(
1074                out,
1075                "{}",
1076                if ja {
1077                    format!("- ほか {more} 件")
1078                } else {
1079                    format!("- and {more} more")
1080                }
1081            );
1082        }
1083        let seats: Vec<String> = c
1084            .rejecters
1085            .iter()
1086            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1087            .collect();
1088        let _ = write!(
1089            out,
1090            "{} {}",
1091            if ja {
1092                "却下したレビュアー:"
1093            } else {
1094                "Rejected by reviewer:"
1095            },
1096            seats.join(", ")
1097        );
1098        out
1099    }
1100
1101    /// The truncation note, written whole in each language for the same reason.
1102    fn truncated_note(
1103        &self,
1104        omitted: usize,
1105        total: usize,
1106        shown: usize,
1107        where_: &str,
1108        base: &str,
1109        head: &str,
1110    ) -> String {
1111        if self.html_lang == "ja" {
1112            format!(
1113                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1114                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1115                 プルリクエストにあります。"
1116            )
1117        } else {
1118            format!(
1119                "{omitted} of {total} diff lines omitted after the first {shown}. \
1120                 The whole patch is in <code>{where_}</code> \
1121                 (<code>git diff {base}...{head}</code>) and on the pull request."
1122            )
1123        }
1124    }
1125}
1126
1127/// Pick the panel's language. Codes and names both, because `[graph] language`
1128/// has always accepted either.
1129fn words(language: &str) -> &'static Words {
1130    if crate::lang::is_japanese(language) {
1131        &JA
1132    } else {
1133        &EN
1134    }
1135}
1136
1137/// The approval panel's html: what is about to land, and the evidence for it.
1138///
1139/// Pure, so the whole document is asserted in tests without `gh`, without a
1140/// network and without a repository. The caller gathers `diffstat`
1141/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1142/// being squashed) and `subject` (what the squash will be called) from the
1143/// winner's worktree.
1144///
1145/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1146/// content security policy blocks all three: anything of the sort here would be
1147/// dead markup that misleads the next reader into thinking it works.
1148pub fn approval_panel(
1149    state: &RunState,
1150    pr: &PrState,
1151    diffstat: &str,
1152    diff: &str,
1153    commits: &[String],
1154    subject: &str,
1155) -> String {
1156    let rows = parse_numstat(diffstat);
1157    let w = words(&state.config.graph.language);
1158    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1159
1160    let _ = writeln!(
1161        h,
1162        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1163         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1164        w.html_lang
1165    );
1166    let _ = writeln!(
1167        h,
1168        "<title>merge #{} — {}</title>\n</head>",
1169        pr.number,
1170        esc(subject)
1171    );
1172    h.push_str(
1173        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1174         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1175         word-break:break-word\">\n",
1176    );
1177
1178    // The decision, in the words the operator is approving.
1179    let _ = writeln!(
1180        h,
1181        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1182         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1183         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1184         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1185         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1186        pr.number,
1187        esc(&state.base_branch),
1188        esc(subject),
1189        esc(&state.id),
1190        esc(&pr.url),
1191        esc(&pr.url),
1192    );
1193
1194    // The task, verbatim: the operator's own words for what was asked, so the
1195    // panel does not make them reconstruct the request from a diffstat.
1196    let _ = writeln!(
1197        h,
1198        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1199         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1200        w.task,
1201        esc(&state.instruction)
1202    );
1203
1204    // The winner's own account of what it did and why, when there is one.
1205    if let Some(summary) = state
1206        .winner()
1207        .map(|c| c.summary.as_str())
1208        .filter(|s| !s.is_empty())
1209    {
1210        let _ = writeln!(
1211            h,
1212            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1213             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1214            w.what_changed,
1215            esc(summary)
1216        );
1217    }
1218
1219    // The verdict from the round that actually cleared this for merge - the
1220    // last one, since only that round's word is still standing.
1221    if let Some(round) = state.reviews.last() {
1222        let _ = writeln!(
1223            h,
1224            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1225            w.review_verdict
1226        );
1227        for r in &round.reviews {
1228            // A seat the review loop counted as answered has real prose in
1229            // `summary`; one it counted against `incomplete` (see
1230            // `graph::Runner::review_loop`) never produced any and left it
1231            // empty - which must not be read back as a blank verdict, since
1232            // an empty box here looks like "nothing to say" rather than
1233            // "never answered".
1234            let body = match &r.failed {
1235                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1236                None => esc(&r.summary),
1237            };
1238            let _ = writeln!(
1239                h,
1240                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1241                 border-radius:6px\">\
1242                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1243                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1244                w.reviewer,
1245                r.reviewer,
1246                esc(&r.agent),
1247                body,
1248            );
1249        }
1250    }
1251
1252    let _ = writeln!(
1253        h,
1254        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1255        w.checks,
1256        esc(pr.checks.as_str())
1257    );
1258    if pr.failing.is_empty() {
1259        let _ = writeln!(
1260            h,
1261            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1262            w.nothing_failing
1263        );
1264    } else {
1265        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1266        for f in &pr.failing {
1267            let _ = writeln!(h, "<li>{}</li>", esc(f));
1268        }
1269        h.push_str("</ul>\n");
1270    }
1271
1272    // Diffstat as a real table, so a phone reads what moved without scrolling
1273    // sideways through a terminal bar chart.
1274    let _ = writeln!(
1275        h,
1276        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1277        rows.len(),
1278        w.files_changed
1279    );
1280    h.push_str(
1281        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1282         <thead><tr>\
1283         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1284         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1285         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1286         </th></tr></thead>\n<tbody>\n",
1287    );
1288    let mut total_added = 0u64;
1289    let mut total_removed = 0u64;
1290    for r in &rows {
1291        total_added += r.added.unwrap_or(0);
1292        total_removed += r.removed.unwrap_or(0);
1293        let cell = |n: Option<u64>| match n {
1294            Some(n) => n.to_string(),
1295            None => "bin".to_owned(),
1296        };
1297        let _ = writeln!(
1298            h,
1299            "<tr>\
1300             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1301             font-family:ui-monospace,monospace\">{}</td>\
1302             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1303             color:#0a3622\">{}</td>\
1304             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1305             color:#5c1a17\">{}</td></tr>",
1306            esc(&r.path),
1307            cell(r.added),
1308            cell(r.removed),
1309        );
1310    }
1311    let _ = writeln!(
1312        h,
1313        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1314         <td style=\"padding:4px 2px\">total</td>\
1315         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1316         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1317         </tr></tfoot>\n</table>"
1318    );
1319
1320    // The commits being squashed, and the subject that replaces them.
1321    let _ = writeln!(
1322        h,
1323        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1324        w.commits
1325    );
1326    if commits.is_empty() {
1327        h.push_str(&format!(
1328            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1329            w.no_commits
1330        ));
1331    } else {
1332        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1333        for c in commits {
1334            let _ = writeln!(h, "<li>{}</li>", esc(c));
1335        }
1336        h.push_str("</ol>\n");
1337    }
1338    let _ = writeln!(
1339        h,
1340        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1341        w.lands_as,
1342        esc(subject),
1343        w.lands_as_tail()
1344    );
1345
1346    // The review comments that shaped this branch, and who asked for them.
1347    let _ = writeln!(
1348        h,
1349        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1350        w.comments
1351    );
1352    if pr.review_comments.is_empty() {
1353        h.push_str(&format!(
1354            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1355            w.no_comments
1356        ));
1357    } else {
1358        for c in &pr.review_comments {
1359            let anchor = match (&c.path, c.line) {
1360                (Some(p), Some(l)) => format!("{p}:{l}"),
1361                (Some(p), None) => p.clone(),
1362                _ => "pull request thread".to_owned(),
1363            };
1364            let _ = writeln!(
1365                h,
1366                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1367                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1368                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1369                esc(&c.author),
1370                esc(&anchor),
1371                esc(&tail(&c.body, 800)),
1372            );
1373        }
1374    }
1375
1376    // The patch itself.
1377    let total = diff.lines().count();
1378    let shown = total.min(DIFF_MAX_LINES);
1379    let _ = writeln!(
1380        h,
1381        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1382        w.diff
1383    );
1384    h.push_str(
1385        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1386         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1387    );
1388    for line in diff.lines().take(shown) {
1389        let (gutter, style, body) = diff_row(line);
1390        let _ = writeln!(
1391            h,
1392            "<div style=\"display:flex;{style}\">\
1393             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1394             border-right:1px solid #d0d7de\">{gutter}</span>\
1395             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1396            esc(body),
1397        );
1398    }
1399    h.push_str("</div>\n");
1400    if total > shown {
1401        let omitted = total - shown;
1402        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1403        let where_ = state.winner().map_or_else(
1404            || state.repo.display().to_string(),
1405            |w| w.worktree.display().to_string(),
1406        );
1407        let _ = writeln!(
1408            h,
1409            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1410             font-size:13px\">{}: {}</p>",
1411            w.truncated,
1412            w.truncated_note(
1413                omitted,
1414                total,
1415                shown,
1416                &esc(&where_),
1417                &esc(&state.base_branch),
1418                &esc(head),
1419            ),
1420        );
1421    }
1422
1423    h.push_str("</body>\n</html>\n");
1424    h
1425}
1426
1427/// The contested hand-off `land` must ask about, if any: recorded by the
1428/// review loop and not switched off by `graph.hold_contested_merge`. The one
1429/// place `land` reads that record.
1430fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1431    if state.config.graph.hold_contested_merge {
1432        state.contested_handoff.clone()
1433    } else {
1434        None
1435    }
1436}
1437
1438/// What a merge-approval question's deputy is told: the pull request, the run,
1439/// what each answer does, and - when the run's record is readable - the
1440/// contested hand-off the question was filed over.
1441///
1442/// A snapshot taken when the deputy is attached, so it says so and points at
1443/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1444/// that cannot be read; what is missing is named as missing, and no past
1445/// approval basis is rebuilt from today's state.
1446pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1447    let mut s = format!(
1448        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1449         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1450         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1451         request open. Silence is a hold: the owner not answering never merges. Only \
1452         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1453         own words, merges. Whether their wording is a clear, unconditional instruction \
1454         is your judgement alone: if it is doubtful, conditional, retracted or a \
1455         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1456         silence are a hold.\n\n\
1457         This brief is a snapshot from when you were attached: check `magi show {run}` \
1458         and `gh pr view` (read-only) before telling the owner anything current. \
1459         You run with permission to write the question record, and what keeps you \
1460         from touching anything else is this brief and your instructions - so do \
1461         not change files, branches or the pull request.",
1462        run = q.run
1463    );
1464    if q.choices.iter().any(|c| c == REPAIR) {
1465        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1466    }
1467    let Some(state) = state else {
1468        s.push_str(
1469            "\n\nThe run's record could not be read, so the pull request, the panel \
1470             summary and any contested findings are not known to you beyond the \
1471             question's own text. Say so to the owner rather than guessing.",
1472        );
1473        return s;
1474    };
1475    if let Some(pr) = &state.pr {
1476        s.push_str(&format!(
1477            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1478            pr.number, pr.url, pr.state
1479        ));
1480    }
1481    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1482    if let Some(w) = state.winner() {
1483        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1484    }
1485    match contested_to_ask(state) {
1486        Some(c) => {
1487            s.push_str(
1488                "\n\nThis question was filed although merge approvals are off, because \
1489                 the review hand-off is contested. Open findings:",
1490            );
1491            for f in &c.findings {
1492                let at = match (&f.file, f.line) {
1493                    (Some(file), Some(line)) => format!(" ({file}:{line})"),
1494                    (Some(file), None) => format!(" ({file})"),
1495                    _ => String::new(),
1496                };
1497                s.push_str(&format!("\n- [{}] {:?}{at}: {}", f.id, f.severity, f.title));
1498            }
1499            let seats: Vec<String> = c.rejecters.iter().map(|(n, _)| format!("#{n}")).collect();
1500            s.push_str(&format!("\nReviewers who rejected: {}.", seats.join(", ")));
1501        }
1502        None => s.push_str("\n\nThe review hand-off was not recorded as contested."),
1503    }
1504    s
1505}
1506
1507/// Ask the owner before merging, with the whole case attached as a panel.
1508///
1509/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1510/// never from the network, so a phone on a slow link gets the diff magi is
1511/// looking at rather than a link it has to go and open.
1512///
1513/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1514/// for up to a day right here, which held the whole run's task claim - and
1515/// the daemon's one slot with it - for exactly as long as the owner took to
1516/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1517/// caller park the run and hand the slot back instead: the question is on
1518/// disk either way, so nothing about the wait itself changes, only who is
1519/// blocked on it.
1520///
1521/// Idempotent across resumes: called again for a run already waiting on its
1522/// own question, this finds that question by [`crate::ask::Questions::list`]
1523/// rather than filing a second one - asking twice would double the
1524/// notification for one decision, and leave the first question's panel an
1525/// orphan nobody's answer ever reaches.
1526async fn approval_gate(
1527    state: &mut RunState,
1528    pr: &PrState,
1529    subject: &str,
1530    contested: Option<&ContestedHandoff>,
1531    head: &str,
1532) -> Result<ApprovalGate> {
1533    let store = ask::Questions::open();
1534    // An answer belongs to the commit its panel showed. A question recorded
1535    // for another head - or none recorded at all, as for a question filed
1536    // before heads were tracked - is never reused: a fix or rebase push made
1537    // a commit the owner has not seen, and their word does not carry over.
1538    let reusable = state
1539        .land_approval
1540        .as_ref()
1541        .filter(|a| a.head.eq_ignore_ascii_case(head))
1542        .and_then(|a| store.list().into_iter().find(|q| q.id == a.question));
1543    if reusable.is_none() {
1544        for stale in store
1545            .list()
1546            .into_iter()
1547            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1548        {
1549            let why = "the pull request moved to a different head commit; asked again about it";
1550            if let Err(e) = store.update(&stale.id, |q| {
1551                q.abandon(why);
1552                Ok(())
1553            }) {
1554                tracing::warn!("could not retire the superseded approval question: {e:#}");
1555            }
1556        }
1557    }
1558
1559    let q = match reusable {
1560        Some(mut q) => {
1561            if upgrade_repair_choice(&mut q, state) {
1562                q = store
1563                    .update(&q.id, |current| {
1564                        upgrade_repair_choice(current, state);
1565                        Ok(())
1566                    })?
1567                    .0;
1568            }
1569            q
1570        }
1571        None => {
1572            let worktree = match state.winner() {
1573                Some(w) => w.worktree.clone(),
1574                None => state.repo.clone(),
1575            };
1576            // The diff is built from the commit being approved, not from the
1577            // branch name, which may already point somewhere else.
1578            let head = if head.is_empty() {
1579                state
1580                    .winner()
1581                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1582            } else {
1583                head.to_owned()
1584            };
1585            // The diff is against what the remote's base holds, never the
1586            // local branch of that name; unreadable means less evidence.
1587            let remote = &state.config.merge.remote;
1588            let tracking = format!("{remote}/{}", state.base_branch);
1589            let base = if git::rev_exists(&worktree, &tracking).await {
1590                tracking
1591            } else {
1592                String::new()
1593            };
1594            let range = format!("{base}...{head}");
1595            // A failed `git` must not decide the merge: the panel degrades to
1596            // less evidence and the owner still chooses. Merging because the
1597            // diff could not be read would be the worst of both.
1598            let numstat = if base.is_empty() {
1599                String::new()
1600            } else {
1601                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1602                    .await
1603                    .map(|o| o.stdout)
1604                    .unwrap_or_default()
1605            };
1606            let diff = if base.is_empty() {
1607                String::new()
1608            } else {
1609                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1610            };
1611            let commits: Vec<String> = if base.is_empty() {
1612                Vec::new()
1613            } else {
1614                git::git_raw(
1615                    &worktree,
1616                    &[
1617                        "log",
1618                        "--reverse",
1619                        "--format=%s",
1620                        &format!("{base}..{head}"),
1621                    ],
1622                )
1623                .await
1624                .map(|o| o.stdout)
1625                .unwrap_or_default()
1626                .lines()
1627                .filter(|l| !l.trim().is_empty())
1628                .map(str::to_owned)
1629                .collect()
1630            };
1631
1632            let w = words(&state.config.graph.language);
1633            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1634            let mut fresh = ask::Question::new(
1635                state.id.clone(),
1636                APPROVAL_NODE.to_owned(),
1637                "land".to_owned(),
1638                w.approval_summary(pr.number, subject),
1639                w.approval_detail(&pr.url, &state.base_branch, subject, contested),
1640                approval_choices(state),
1641            );
1642            if fresh.choices.iter().any(|c| c == REPAIR) {
1643                fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1644            }
1645            store
1646                .put_panel(&mut fresh, &html, &[])
1647                .context("write the merge approval panel")?;
1648            store
1649                .put(&mut fresh)
1650                .context("file the merge approval question")?;
1651            state.land_approval = Some(LandApproval {
1652                question: fresh.id.clone(),
1653                head: head.clone(),
1654            });
1655            state.event(
1656                "land",
1657                format!("asking for merge approval ({})", fresh.short()),
1658            );
1659            state.save()?;
1660            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1661                // A broken webhook is not a reason to lose the merge: the
1662                // question is already on disk and the web UI already shows
1663                // it, so the operator still has a way in.
1664                tracing::warn!(
1665                    "could not notify about merge approval question {}: {e:#} - \
1666                     the web UI is the only surface for it now",
1667                    fresh.short()
1668                );
1669            }
1670            fresh
1671        }
1672    };
1673
1674    Ok(match q.status {
1675        ask::QuestionStatus::Open => ApprovalGate::Pending,
1676        // Nobody answered before `state.config.graph.answer_timeout` passed,
1677        // or the question was closed with no decision recorded underneath
1678        // this run - either way there is nothing left to wait on.
1679        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1680        // The merge gate does not speak `--thread`: an owner who talked back
1681        // instead of choosing never reaches `Answered`, so this arm only
1682        // ever sees an actual decision.
1683        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1684            Approval::Merge => ApprovalGate::Approved,
1685            Approval::Repair if repair_available(state) => {
1686                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1687                    state.merge_repairs.push(crate::run::MergeRepair {
1688                        question: q.id.clone(),
1689                        head: head.to_owned(),
1690                        pr: pr.url.clone(),
1691                        reviews: state.reviews.clone(),
1692                        gate: state.gate.clone(),
1693                        gate_head: state.gate_head.clone(),
1694                        gate_fixes: state.gate_fixes.clone(),
1695                        pre_gate: state.pre_gate.clone(),
1696                        phase: crate::run::MergeRepairPhase::Requested,
1697                        failure: None,
1698                    });
1699                    state.land_approval = None;
1700                    state.event(
1701                        "repair",
1702                        "owner requested fix findings and re-review; merge is not approved",
1703                    );
1704                    state.save()?;
1705                }
1706                ApprovalGate::Repair
1707            }
1708            Approval::Repair | Approval::Hold => ApprovalGate::Held,
1709        },
1710    })
1711}
1712
1713/// Fold a rollup's entries into one verdict plus the failing checks' labels.
1714/// No I/O. An empty rollup is `Unknown`, never green.
1715fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1716    let mut failing = Vec::new();
1717    let mut pending = false;
1718    let mut unknown = false;
1719    for check in rollup {
1720        match check.verdict() {
1721            Verdict::Pass => {}
1722            Verdict::Pending => pending = true,
1723            Verdict::Fail => failing.push(check.label()),
1724            Verdict::Unknown => unknown = true,
1725        }
1726    }
1727    let checks = if rollup.is_empty() {
1728        Checks::Unknown
1729    } else if pending {
1730        Checks::Pending
1731    } else if !failing.is_empty() {
1732        Checks::Red
1733    } else if unknown {
1734        Checks::Unknown
1735    } else {
1736        Checks::Green
1737    };
1738    (checks, failing)
1739}
1740
1741/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1742/// output into a [`PrState`]. No I/O.
1743pub fn parse_pr(json: &str) -> Result<PrState> {
1744    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1745    let state = match raw.state.to_ascii_uppercase().as_str() {
1746        "OPEN" => PrLifecycle::Open,
1747        "MERGED" => PrLifecycle::Merged,
1748        "CLOSED" => PrLifecycle::Closed,
1749        other => bail!("unknown pull request state `{other}`"),
1750    };
1751
1752    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
1753
1754    let mut review_comments = Vec::new();
1755    for r in raw.reviews {
1756        push_if_outstanding(
1757            &mut review_comments,
1758            ReviewComment {
1759                author: r.author.login,
1760                path: None,
1761                line: None,
1762                body: r.body,
1763            },
1764        );
1765    }
1766    for c in raw.comments {
1767        push_if_outstanding(
1768            &mut review_comments,
1769            ReviewComment {
1770                author: c.author.login,
1771                path: None,
1772                line: None,
1773                body: c.body,
1774            },
1775        );
1776    }
1777
1778    Ok(PrState {
1779        url: raw.url,
1780        number: raw.number,
1781        state,
1782        checks,
1783        failing,
1784        review_comments,
1785        blocking: Blocking::of(&raw.merge_state_status),
1786    })
1787}
1788
1789/// One rollup entry as the release watcher reads it.
1790#[derive(Debug, Clone, PartialEq, Eq)]
1791pub(crate) struct CheckView {
1792    pub name: String,
1793    pub verdict: Verdict,
1794    /// Workflow run behind the check, when its url names one.
1795    pub run: Option<String>,
1796    pub url: Option<String>,
1797}
1798
1799/// A pull request's lifecycle, head commit and per-check verdicts, without
1800/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
1801/// from it, which is exactly what the release watcher must not inherit).
1802#[derive(Debug, Clone, PartialEq, Eq)]
1803pub(crate) struct RollupView {
1804    pub url: String,
1805    pub number: u64,
1806    pub state: PrLifecycle,
1807    pub head: String,
1808    pub checks: Vec<CheckView>,
1809}
1810
1811/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
1812/// output. No I/O.
1813pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
1814    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1815    let state = match raw.state.to_ascii_uppercase().as_str() {
1816        "OPEN" => PrLifecycle::Open,
1817        "MERGED" => PrLifecycle::Merged,
1818        "CLOSED" => PrLifecycle::Closed,
1819        other => bail!("unknown pull request state `{other}`"),
1820    };
1821    let checks = raw
1822        .status_check_rollup
1823        .iter()
1824        .map(|c| CheckView {
1825            name: c.label(),
1826            verdict: c.verdict(),
1827            run: c.url().and_then(run_of),
1828            url: c.url().map(str::to_owned),
1829        })
1830        .collect();
1831    Ok(RollupView {
1832        url: raw.url,
1833        number: raw.number,
1834        state,
1835        head: raw.head_ref_oid,
1836        checks,
1837    })
1838}
1839
1840/// Read just a pull request's lifecycle state - open, merged, or closed -
1841/// with none of the checks/reviews/comments [`land`] itself needs to decide
1842/// what to do next.
1843///
1844/// For a caller that only ever wants one fact and must not risk anything
1845/// else: `magi fold --merged` uses this to confirm a URL the operator hands
1846/// it is actually a merged pull request *before* touching a run's state, so a
1847/// typo or a still-open PR fails loudly instead of quietly recording a merge
1848/// that never happened.
1849pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
1850    let view = gh(
1851        repo,
1852        &[
1853            "pr".to_owned(),
1854            "view".to_owned(),
1855            pr_url.to_owned(),
1856            "--json".to_owned(),
1857            "state".to_owned(),
1858        ],
1859    )
1860    .await?;
1861    if !view.0 {
1862        bail!("gh pr view {pr_url}: {}", view.1);
1863    }
1864    // `parse_pr` reads every other field of `GhPr` as its serde default
1865    // (empty string, empty vec, zero) when this narrower `--json` selection
1866    // does not carry them - harmless, since only `.state` is read back.
1867    Ok(parse_pr(&view.1)?.state)
1868}
1869
1870/// A pull request the operator merged outside of `land::land`'s own loop,
1871/// found by asking GitHub about the run's own winning branch rather than
1872/// requiring the operator to go and find the URL themselves.
1873#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1874pub struct ExternalMerge {
1875    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
1876    pub url: String,
1877    /// The pull request's number.
1878    pub number: u64,
1879}
1880
1881#[derive(Debug, Deserialize)]
1882#[serde(rename_all = "camelCase")]
1883struct GhMergedPr {
1884    url: String,
1885    number: u64,
1886    merged_at: String,
1887    base_ref_name: String,
1888}
1889
1890/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
1891/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
1892/// decide whether exactly one of the pull requests it lists could actually
1893/// be *this* run's.
1894///
1895/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
1896/// from the run's own short id, so a collision with some other, unrelated
1897/// task's merged pull request from a same-named branch is rare but not
1898/// impossible once branches are deleted and ids run out. Filtering on
1899/// `base_ref_name` (the branch this run actually targets) and `merged_at`
1900/// (which cannot predate the run itself) rules that case out. More than one
1901/// survivor is exactly as uninformative as zero — something this run cannot
1902/// tell apart from another — so only a unique survivor is returned.
1903fn pick_merged_pr(
1904    json: &str,
1905    base_branch: &str,
1906    created_at: Timestamp,
1907) -> Result<Option<ExternalMerge>> {
1908    let raw: Vec<GhMergedPr> =
1909        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1910    let mut matches: Vec<ExternalMerge> = Vec::new();
1911    for pr in raw {
1912        if pr.base_ref_name != base_branch {
1913            continue;
1914        }
1915        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
1916            continue;
1917        };
1918        if merged_at < created_at {
1919            continue;
1920        }
1921        matches.push(ExternalMerge {
1922            url: pr.url,
1923            number: pr.number,
1924        });
1925    }
1926    if matches.len() == 1 {
1927        Ok(matches.pop())
1928    } else {
1929        Ok(None)
1930    }
1931}
1932
1933/// What `gh pr list --head <branch> --base <base> --state open` found.
1934#[derive(Debug, Clone, PartialEq, Eq)]
1935pub enum OpenPr {
1936    /// Nothing open: the caller creates one.
1937    None,
1938    /// Exactly one: the caller adopts it instead of creating a second.
1939    One {
1940        /// The pull request's URL.
1941        url: String,
1942        /// Its current title.
1943        title: String,
1944    },
1945    /// More than one: magi does not pick between them.
1946    Many(Vec<String>),
1947}
1948
1949#[derive(Debug, Deserialize)]
1950#[serde(rename_all = "camelCase")]
1951struct GhOpenPr {
1952    // `url` and `baseRefName` are required: a record missing either must be a
1953    // parse error, not a pull request that silently fails the base filter and
1954    // reads as "none open" (which would go on to create a duplicate).
1955    url: String,
1956    #[serde(default)]
1957    title: String,
1958    base_ref_name: String,
1959    // A fork's branch of the same name is not the task head; `gh pr list --head`
1960    // matches by branch name alone, so the filter has to be ours.
1961    #[serde(default)]
1962    is_cross_repository: bool,
1963}
1964
1965/// Pure half of [`find_open_pr`]: classify the raw `--json
1966/// number,url,title,baseRefName` output. Entries whose base is not `base` are
1967/// dropped even though the query already filtered on it, so a stub or an old
1968/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
1969/// adopted.
1970pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
1971    let raw: Vec<GhOpenPr> =
1972        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
1973    let mut hits: Vec<GhOpenPr> = raw
1974        .into_iter()
1975        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
1976        .collect();
1977    Ok(match hits.len() {
1978        0 => OpenPr::None,
1979        1 => {
1980            let p = hits.remove(0);
1981            OpenPr::One {
1982                url: p.url,
1983                title: p.title,
1984            }
1985        }
1986        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
1987    })
1988}
1989
1990/// Open pull requests whose head is `branch` and whose base is `base`. A
1991/// failing `gh` is an error carrying its own output, never "none": guessing
1992/// there is how a duplicate gets created.
1993pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
1994    let (ok, out) = gh(
1995        repo,
1996        &[
1997            "pr".to_owned(),
1998            "list".to_owned(),
1999            "--head".to_owned(),
2000            branch.to_owned(),
2001            "--base".to_owned(),
2002            base.to_owned(),
2003            "--state".to_owned(),
2004            "open".to_owned(),
2005            "--json".to_owned(),
2006            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2007        ],
2008    )
2009    .await?;
2010    if !ok {
2011        bail!("gh pr list failed: {out}");
2012    }
2013    pick_open_pr(&out, base)
2014}
2015
2016#[derive(Debug, Deserialize)]
2017#[serde(rename_all = "camelCase")]
2018struct GhPrHead {
2019    head_ref_name: String,
2020    base_ref_name: String,
2021    state: String,
2022    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2023    // head lives in a fork must be a parse error, never "same repository".
2024    is_cross_repository: bool,
2025    // Required too: it is what ties the pull request to the commits that were
2026    // actually proven to be on the base.
2027    head_ref_oid: String,
2028}
2029
2030/// Why [`closable`] said no, and whether asking again later could say yes.
2031#[derive(Debug, Clone, PartialEq, Eq)]
2032pub struct Refusal {
2033    /// A later attempt may succeed (the head moved, the view was unreadable);
2034    /// `false` means this pull request is simply not the run's to close.
2035    pub retry: bool,
2036    /// What was wrong.
2037    pub why: String,
2038}
2039
2040impl Refusal {
2041    fn final_(why: String) -> Self {
2042        Self { retry: false, why }
2043    }
2044}
2045
2046/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2047/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2048/// is safe, `Err` carrying the reason when it is not.
2049///
2050/// Closing is outward-facing, so every property is checked on what the forge
2051/// says now, not on what magi recorded: the head must be exactly `branch` in
2052/// this repository (a fork's branch of the same name is somebody else's), the
2053/// base must be `base`, and the pull request must still be open.
2054pub fn closable(
2055    json: &str,
2056    branch: &str,
2057    base: &str,
2058    verified: &[String],
2059) -> std::result::Result<(), Refusal> {
2060    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2061        retry: true,
2062        why: format!("could not read the pull request ({e})"),
2063    })?;
2064    if pr.head_ref_name != branch {
2065        return Err(Refusal::final_(format!(
2066            "its head is `{}`, not this run's `{branch}`",
2067            pr.head_ref_name
2068        )));
2069    }
2070    if pr.is_cross_repository {
2071        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2072    }
2073    if pr.base_ref_name != base {
2074        return Err(Refusal::final_(format!(
2075            "it targets `{}`, not `{base}`",
2076            pr.base_ref_name
2077        )));
2078    }
2079    if !pr.state.eq_ignore_ascii_case("open") {
2080        return Err(Refusal::final_(format!(
2081            "it is already {}",
2082            pr.state.to_ascii_lowercase()
2083        )));
2084    }
2085    // Last, so a pull request that is not this run's at all is reported as
2086    // such. A head that is this branch but not a commit checked against the
2087    // base (somebody pushed since) may well get checked next time: retry.
2088    if !verified.contains(&pr.head_ref_oid) {
2089        return Err(Refusal {
2090            retry: true,
2091            why: format!(
2092                "its head {} is not a commit this run checked against the base",
2093                crate::already::short_sha(&pr.head_ref_oid)
2094            ),
2095        });
2096    }
2097    Ok(())
2098}
2099
2100/// Does `remote` point at something a forge could host - a URL or an scp-style
2101/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2102/// from the URL alone, so it answers the same on every machine, whatever `gh`
2103/// happens to be installed or logged in to.
2104async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2105    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2106        return false;
2107    };
2108    is_forge_url(url.trim())
2109}
2110
2111fn is_forge_url(url: &str) -> bool {
2112    url.contains("://") && !url.starts_with("file://")
2113        || url
2114            .split_once(':')
2115            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2116}
2117
2118/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2119/// request that failed?
2120fn forge_unavailable(message: &str) -> bool {
2121    message.contains("known GitHub host") || message.contains("spawn gh")
2122}
2123
2124/// The comment left on a pull request closed because its change is already on
2125/// the base.
2126pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2127    let how = match evidence.proof {
2128        crate::already::Proof::PatchId => format!(
2129            "carried by commit {} on `{base}` with the same patch",
2130            evidence.names()
2131        ),
2132        crate::already::Proof::Ancestry => {
2133            format!("already in the history of `{base}` as {}", evidence.names())
2134        }
2135        crate::already::Proof::Tree => format!(
2136            "already part of `{base}` (merging this branch changes nothing at {})",
2137            crate::already::short_sha(&evidence.tip)
2138        ),
2139    };
2140    format!(
2141        "Closing: everything this branch adds is {how}, so there is nothing left to \
2142         land. This pull request was closed automatically after that was verified; \
2143         reopen it if you disagree."
2144    )
2145}
2146
2147/// Close the open pull request for `branch`, if there is one and it is
2148/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2149/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2150/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2151/// failed `gh` call, a head that moved since it was checked), which the caller
2152/// must not treat as settled.
2153///
2154/// The recorded `state.pr` is preferred, else the forge is asked for an open
2155/// pull request on `branch`; either way the candidate is re-read and passed
2156/// through [`closable`] before anything is written; `verified` lists the
2157/// commits proven to be on the base, and the pull request's head must be one.
2158pub async fn close_superseded_pr(
2159    state: &mut RunState,
2160    branch: &str,
2161    evidence: &crate::already::Evidence,
2162    verified: &[String],
2163) -> Result<std::result::Result<String, String>> {
2164    let repo = state.repo.clone();
2165    let base = state.base_branch.clone();
2166    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2167        Some(p) => p.url.clone(),
2168        // No recorded pull request. A forge that cannot be asked at all (no
2169        // GitHub remote, no `gh`) says nothing about this run, so that is
2170        // "none found"; any other lookup failure is an error, because "could
2171        // not look" is not "nothing there" and the caller must retry.
2172        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2173            return Ok(Err(
2174                "the remote is not a forge, so there is no pull request".to_owned(),
2175            ));
2176        }
2177        None => match find_open_pr(&repo, branch, &base).await {
2178            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2179                return Ok(Err(format!("no forge to ask: {e:#}")));
2180            }
2181            Err(e) => return Err(e),
2182            Ok(OpenPr::One { url, .. }) => url,
2183            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2184            Ok(OpenPr::Many(urls)) => {
2185                return Ok(Err(format!(
2186                    "{} open pull requests name it; not choosing between them",
2187                    urls.len()
2188                )));
2189            }
2190        },
2191    };
2192    let (ok, view) = gh(
2193        &repo,
2194        &[
2195            "pr".to_owned(),
2196            "view".to_owned(),
2197            url.clone(),
2198            "--json".to_owned(),
2199            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2200        ],
2201    )
2202    .await?;
2203    if !ok {
2204        bail!("gh pr view {url} failed: {view}");
2205    }
2206    if let Err(refusal) = closable(&view, branch, &base, verified) {
2207        // A pull request whose head cannot be tied to what was proven is not
2208        // one to walk away from: the caller keeps the run resumable.
2209        if refusal.retry {
2210            bail!("left {url} open: {}", refusal.why);
2211        }
2212        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2213    }
2214    let (ok, out) = gh(
2215        &repo,
2216        &[
2217            "pr".to_owned(),
2218            "close".to_owned(),
2219            url.clone(),
2220            "--comment".to_owned(),
2221            superseded_comment(&base, evidence),
2222        ],
2223    )
2224    .await?;
2225    if !ok {
2226        bail!("gh pr close {url} failed: {out}");
2227    }
2228    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2229        p.state = "closed".to_owned();
2230    }
2231    Ok(Ok(url))
2232}
2233
2234/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2235/// differs from the one this run computed. Only the title: the body may have
2236/// been edited by the owner and cannot be compared.
2237pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2238    let (title, _) = crate::github_text::prepare(state, title, "");
2239    edit_pr_title(repo, url, &title).await
2240}
2241
2242/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2243/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2244/// gate is skipped on purpose.
2245pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2246    edit_pr_title(repo, url, title).await
2247}
2248
2249/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2250/// unmodified and without the posting gate.
2251pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2252    let (ok, _) = gh(
2253        repo,
2254        &[
2255            "pr".to_owned(),
2256            "edit".to_owned(),
2257            url.to_owned(),
2258            "--title".to_owned(),
2259            title.to_owned(),
2260            "--body".to_owned(),
2261            body.to_owned(),
2262        ],
2263    )
2264    .await
2265    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2266    if !ok {
2267        // Command output may echo the chosen text; keep it out of run events.
2268        bail!("gh pr edit failed while posting the chosen rewrite");
2269    }
2270    Ok(())
2271}
2272
2273async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2274    let (ok, out) = gh(
2275        repo,
2276        &[
2277            "pr".to_owned(),
2278            "edit".to_owned(),
2279            url.to_owned(),
2280            "--title".to_owned(),
2281            title.to_owned(),
2282        ],
2283    )
2284    .await?;
2285    if !ok {
2286        bail!("gh pr edit failed: {out}");
2287    }
2288    Ok(())
2289}
2290
2291/// Ask GitHub whether this run's winning candidate branch was actually merged
2292/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2293/// --merged` exists to close, minus the operator having to find the URL by
2294/// hand.
2295///
2296/// `Ok(None)` covers every case where nothing can be said with confidence: no
2297/// winner decided yet (nothing to check a branch for), no merged pull request
2298/// found, or [`pick_merged_pr`] found more than one candidate and would not
2299/// guess between them. Never wired to a weaker, URL-less signal like
2300/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2301/// separately, precisely because it cannot drive an automatic correction on
2302/// its own (see that function's own doc).
2303pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2304    let Some(winner) = state.winner() else {
2305        return Ok(None);
2306    };
2307    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2308    let out = gh(
2309        &state.repo,
2310        &[
2311            "pr".to_owned(),
2312            "list".to_owned(),
2313            "--head".to_owned(),
2314            branch.clone(),
2315            "--state".to_owned(),
2316            "merged".to_owned(),
2317            "--json".to_owned(),
2318            "url,number,mergedAt,baseRefName".to_owned(),
2319        ],
2320    )
2321    .await?;
2322    if !out.0 {
2323        bail!("gh pr list --head {branch}: {}", out.1);
2324    }
2325    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2326}
2327
2328/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2329/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2330///
2331/// Deliberately never consulted by [`find_external_merge`]: a base branch
2332/// that has moved since the run started can make an old, abandoned branch
2333/// look like an ancestor of the *current* base for reasons that have nothing
2334/// to do with a merge (a later commit that happens to supersede it, an
2335/// unrelated squash), and there is no pull request URL here to confirm
2336/// against or to land through anyway. Its only honest use is a weaker
2337/// notice — "this looks merged, go check" — never an automatic rewrite of
2338/// `status`/`merge`.
2339pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2340    let out = tokio::process::Command::new("git")
2341        .args(["merge-base", "--is-ancestor", branch, base_branch])
2342        .current_dir(repo)
2343        .quiet()
2344        .stdin(std::process::Stdio::null())
2345        .output()
2346        .await
2347        .context("spawn git merge-base --is-ancestor")?;
2348    Ok(out.status.success())
2349}
2350
2351/// Parse `host/owner/repo` out of a forge URL, with no network access.
2352///
2353/// The host is part of the slug, not discarded: `owner/repo` alone would
2354/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2355/// repository, which is exactly the mix-up the same-repo guard exists to
2356/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2357/// shape at all.
2358fn forge_slug(url: &str) -> Option<(String, &str)> {
2359    let rest = url.rsplit("://").next()?;
2360    let (host, path) = rest.split_once('/')?;
2361    if host.is_empty() {
2362        return None;
2363    }
2364    Some((host.to_ascii_lowercase(), path))
2365}
2366
2367/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2368/// access - the first half of the same-repo guard [`correct_manual_merge`]
2369/// applies before it writes anything.
2370///
2371/// Returns `None` for anything that does not look like
2372/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2373/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2374pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2375    let (host, path) = forge_slug(url)?;
2376    let mut segments = path.split('/');
2377    let owner = segments.next()?;
2378    let repo = segments.next()?;
2379    let kind = segments.next()?;
2380    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2381        return None;
2382    }
2383    Some(format!("{host}/{owner}/{repo}"))
2384}
2385
2386/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2387/// suffix), the shape `gh repo view --json url` returns - the other half of
2388/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2389fn slug_of_repo_url(url: &str) -> Option<String> {
2390    let (host, path) = forge_slug(url)?;
2391    let mut segments = path.split('/');
2392    let owner = segments.next()?;
2393    let repo = segments.next()?;
2394    if owner.is_empty() || repo.is_empty() {
2395        return None;
2396    }
2397    Some(format!("{host}/{owner}/{repo}"))
2398}
2399
2400/// Refuse to correct a run against a pull request from a different
2401/// repository than the one it is recorded against.
2402///
2403/// This is the guard the shun/8c75 incident argued for: an operator ran
2404/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2405/// in a different repository, omitted the run id, and the id defaulted to
2406/// this machine's most recently created run - an unrelated, still-in-progress
2407/// run in a completely different repository - which then had its `status`
2408/// rewritten to `merged` from a pull request it had nothing to do with.
2409/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2410/// CLI help), but a mistyped or stale id could still name a run in a
2411/// different repository than the one the URL belongs to, so this checks that
2412/// independently rather than trusting the id alone.
2413///
2414/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2415/// mismatch names both slugs rather than just refusing, so an operator whose
2416/// local checkout's `origin` is a fork of the repository the pull request was
2417/// opened against (a legitimate setup this cannot tell apart from a genuine
2418/// mix-up) can judge for themselves rather than being blocked with no way to
2419/// see why.
2420pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2421    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2422        return Ok(());
2423    }
2424    bail!(
2425        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2426         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2427         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2428         verify by hand before treating this as a false positive)"
2429    );
2430}
2431
2432/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2433/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2434///
2435/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2436/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2437/// repository the same way GitHub itself would recognize it, so the
2438/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2439/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2440/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2441/// a same-named one on a GitHub Enterprise host.
2442async fn repo_slug(repo: &Path) -> Result<String> {
2443    let out = gh(
2444        repo,
2445        &[
2446            "repo".to_owned(),
2447            "view".to_owned(),
2448            "--json".to_owned(),
2449            "url".to_owned(),
2450        ],
2451    )
2452    .await?;
2453    if !out.0 {
2454        bail!("gh repo view --json url: {}", out.1);
2455    }
2456    #[derive(Debug, Deserialize)]
2457    struct GhRepo {
2458        url: String,
2459    }
2460    let parsed: GhRepo = serde_json::from_str(&out.1)
2461        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2462    slug_of_repo_url(&parsed.url)
2463        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2464}
2465
2466/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2467/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2468/// would have written them had magi opened and merged this pull request
2469/// itself.
2470///
2471/// This is `magi fold --merged`'s whole implementation, and also what the
2472/// web `fold-merged` route calls once it has a URL in hand — an operator
2473/// recovery path for a merge magi could not finish on its own: a PR title too
2474/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2475/// closed by hand with a pull request magi never opened and so never
2476/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2477/// directly keeps this one authoritative: a merged pull request decides
2478/// `Step::Done { merged: true }` on the very first read, before any of
2479/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2480/// safe to call here even though this pull request was never magi's own.
2481///
2482/// [`ensure_same_repo`] is checked before anything else: a pull request from
2483/// a different repository than the one `state` is recorded against is
2484/// refused outright, regardless of its lifecycle. This is the guard for a
2485/// URL an *operator* hands in - the CLI or the web route - where a stale or
2486/// mistyped run id could otherwise get corrected from an unrelated
2487/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2488/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2489/// goes through [`correct_confirmed_external_merge`] instead, which skips
2490/// this check: its `url` was never operator-supplied, it comes from
2491/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2492/// it is already guaranteed to name a pull request in that same repository -
2493/// re-deriving and re-checking the repository here would only be a second
2494/// `gh repo view` call that can fail for reasons that have nothing to do with
2495/// correctness (a rate limit, a network blip), turning a self-heal that would
2496/// otherwise have succeeded into a run left `Blocked` for another pass.
2497///
2498/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2499/// URL fails loudly without writing anything, rather than handing an open
2500/// pull request to the full autonomous loop by accident.
2501///
2502/// Correcting `status` this way does not run `bump::after_merge`
2503/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2504/// which this path never goes through. A release version bump the change
2505/// might have earned is therefore not filed automatically and has to be
2506/// requested by hand - recorded as an event on the run so the gap is visible
2507/// to whoever reads it later, not just wherever this was called from. Follow-up
2508/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2509///
2510/// Returns the status before and after, so every caller (CLI, janitor, web
2511/// route) can build its own log line or response from the same pair rather
2512/// than each re-deriving it.
2513pub async fn correct_manual_merge(
2514    state: &mut RunState,
2515    url: &str,
2516) -> Result<(RunStatus, RunStatus)> {
2517    let Some(pr_slug) = slug_of_pr_url(url) else {
2518        bail!(
2519            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2520             this pull request belongs to"
2521        );
2522    };
2523    let run_slug = repo_slug(&state.repo).await?;
2524    ensure_same_repo(&run_slug, &pr_slug)?;
2525    correct_merge(state, url).await
2526}
2527
2528/// The janitor's own entry point into the same correction
2529/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2530/// same-repo guard - see that function's own doc for why skipping it here is
2531/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2532/// calls this with a `url` [`find_external_merge`] already found by querying
2533/// `state.repo`'s own remote, so the guard could never do anything here but
2534/// fail on its own transient errors.
2535///
2536/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2537/// because it lives in a different module.
2538pub(crate) async fn correct_confirmed_external_merge(
2539    state: &mut RunState,
2540    url: &str,
2541) -> Result<(RunStatus, RunStatus)> {
2542    correct_merge(state, url).await
2543}
2544
2545/// Same pull request, same repository: the url, or the number within one repo.
2546fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2547    let Some(pr) = a.pr.as_ref() else {
2548        return false;
2549    };
2550    if !url.is_empty()
2551        && pr
2552            .url
2553            .trim_end_matches('/')
2554            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2555    {
2556        return true;
2557    }
2558    number > 0
2559        && pr.number == number
2560        && match (a.repo.canonicalize(), repo.canonicalize()) {
2561            (Ok(x), Ok(y)) => x == y,
2562            _ => a.repo == repo,
2563        }
2564}
2565
2566/// Rewrite `pr.state` to a final state on every run record under `home` that
2567/// `decide` picks, and report how many were rewritten.
2568///
2569/// Lifecycle writes through to other runs are deliberately narrow (task-head
2570/// ownership is recorded separately): only a **terminal** run (never one a
2571/// driver may still be writing), never one a live daemon claims, only a record
2572/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2573/// The record is read as folding reads it (no schema check: every bump so far
2574/// only added fields, and the whole struct round-trips) and written through
2575/// [`RunState::save_under`], the path every record uses, so nothing but
2576/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2577/// A run that cannot be read or written is skipped with a warning.
2578fn rewrite_open_prs(
2579    home: &Path,
2580    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2581) -> usize {
2582    let now = Timestamp::now();
2583    let mut changed = 0;
2584    for id in crate::run::list_ids_in(&home.join("runs")) {
2585        let path = home.join("runs").join(&id).join("run.json");
2586        let Ok(body) = std::fs::read_to_string(&path) else {
2587            continue;
2588        };
2589        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2590            continue;
2591        };
2592        if !state.status.done()
2593            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2594            || crate::daemon::is_working_on(home, &id, now)
2595        {
2596            continue;
2597        }
2598        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2599            continue;
2600        };
2601        if let Some(pr) = state.pr.as_mut() {
2602            pr.state = to.as_str().to_owned();
2603        }
2604        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2605        state.event(
2606            "land",
2607            format!("recorded {url} as {}: another run settled it", to.as_str()),
2608        );
2609        match state.save_under(home) {
2610            Ok(()) => changed += 1,
2611            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2612        }
2613    }
2614    changed
2615}
2616
2617/// A run reached a final state for its pull request: tell every other terminal
2618/// run in the same repository that names the same pull request (handed-over
2619/// predecessors, blocked attempts, anything), so their records stop saying
2620/// `open`. Best effort; `run`'s own record is the caller's.
2621pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2622    if to == PrLifecycle::Open {
2623        return;
2624    }
2625    let Some(home) = crate::run::try_home() else {
2626        return;
2627    };
2628    write_pr_state_through_in(&home, run, to);
2629}
2630
2631pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2632    let Some(pr) = run.pr.as_ref() else {
2633        return 0;
2634    };
2635    let (url, number) = (pr.url.clone(), pr.number);
2636    rewrite_open_prs(home, &mut |other| {
2637        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2638    })
2639}
2640
2641/// Terminal runs whose record still says their pull request is open, as
2642/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2643pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2644    let now = Timestamp::now();
2645    let mut out = Vec::new();
2646    for id in crate::run::list_ids_in(&home.join("runs")) {
2647        let path = home.join("runs").join(&id).join("run.json");
2648        let Ok(body) = std::fs::read_to_string(&path) else {
2649            continue;
2650        };
2651        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2652            continue;
2653        };
2654        if let Some(pr) = state.pr.as_ref()
2655            && state.status.done()
2656            && pr.state == "open"
2657            && !pr.url.is_empty()
2658            && !crate::daemon::is_working_on(home, &id, now)
2659        {
2660            out.push((id, state.repo.clone(), pr.url.clone()));
2661        }
2662    }
2663    out
2664}
2665
2666/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2667/// A url with no answer (the forge was unreadable) changes nothing.
2668pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2669    rewrite_open_prs(home, &mut |s| {
2670        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2671    })
2672}
2673
2674/// One-time (and idempotent) repair of records that froze an `open` pull
2675/// request: ask the forge about each distinct pull request that a terminal run
2676/// still calls open - at most `max_lookups` of them - and rewrite the merged
2677/// and closed ones. A genuinely open pull request is left alone, and a lookup
2678/// that fails is "unknown, change nothing". Returns `(records rewritten,
2679/// lookups that failed)`.
2680pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2681    let mut known = BTreeMap::new();
2682    let mut failed = 0;
2683    let mut seen = BTreeSet::new();
2684    for (_, repo, url) in stale_open_prs(home) {
2685        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2686            continue;
2687        }
2688        match lifecycle(&repo, &url).await {
2689            Ok(state) => {
2690                known.insert(url, state);
2691            }
2692            Err(e) => {
2693                tracing::warn!("repair pr state of {url}: {e:#}");
2694                failed += 1;
2695            }
2696        }
2697    }
2698    (apply_pr_states(home, &known), failed)
2699}
2700
2701async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2702    match lifecycle(&state.repo, url).await? {
2703        PrLifecycle::Merged => {}
2704        other => bail!(
2705            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2706            other.as_str(),
2707            state.id
2708        ),
2709    }
2710    let before = state.status;
2711    if let Err(e) = land(state, url).await {
2712        // `land` sets `status` to `Landing` and saves before its first read
2713        // of the pull request — see its own doc — so a failure here (a
2714        // transient `gh` hiccup between the two forge reads this function
2715        // makes) can leave the run stuck on that in-between value with
2716        // nothing left driving it. Land it on the same terminal shape an
2717        // automated `land` failure lands on instead of leaving it stuck.
2718        state.status = RunStatus::Blocked;
2719        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2720        state.save()?;
2721        return Err(e).context(format!("confirming the merge of {url}"));
2722    }
2723    state.event(
2724        "fold",
2725        "operator recorded this pull request as a manual merge; this run never \
2726         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2727         bump this change might warrant has to be filed by hand",
2728    );
2729    // Unlike the bump, the findings the merge left open are filed on this
2730    // path too: nothing else would ever carry them forward.
2731    if state.status == RunStatus::Merged {
2732        crate::followup::after_merge(state, url).await;
2733    }
2734    state.save()?;
2735    Ok((before, state.status))
2736}
2737
2738/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
2739/// comments. No I/O.
2740///
2741/// `gh pr view` does not surface inline comments, and inline is exactly where
2742/// both review bots put their findings - a landing loop that read only the
2743/// top-level thread would never see the thing it is supposed to fix.
2744pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
2745    let raw: Vec<GhInline> =
2746        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
2747    let mut out = Vec::new();
2748    for c in raw {
2749        push_if_outstanding(
2750            &mut out,
2751            ReviewComment {
2752                author: c.user.login,
2753                path: c.path,
2754                line: c.line,
2755                body: c.body,
2756            },
2757        );
2758    }
2759    Ok(out)
2760}
2761
2762/// Keep a comment only when it asks for something.
2763///
2764/// An inline comment always does: it names a file and a line. A top-level
2765/// comment is dropped when it is empty, when it is magi's own, or when it is
2766/// [noise](is_noise).
2767fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
2768    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
2769        return;
2770    }
2771    if comment.path.is_none() && is_noise(&comment.body) {
2772        return;
2773    }
2774    out.push(comment);
2775}
2776
2777/// Is this comment body machinery rather than a finding?
2778///
2779/// Two tests, both structural, because guessing from prose is how a "looks
2780/// good to me" turns into a fix round:
2781///
2782/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
2783///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
2784///    footer.
2785/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
2786///    horizontal rules, and the bot's own status banner are removed, every
2787///    remaining line is a task-list item. That is exactly the shape of the
2788///    comment the Claude review job posts while it is still working.
2789///
2790/// Anything else is input, including bot prose. A bot that writes a paragraph
2791/// has said something, and the fix prompt tells the fixer it may decline a
2792/// comment with an argument - a wasted sentence in a prompt is cheaper than a
2793/// missed finding.
2794pub fn is_noise(body: &str) -> bool {
2795    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
2796        return true;
2797    }
2798    let mut content = false;
2799    for line in strip_blocks(body).lines() {
2800        let line = unquote(line);
2801        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
2802            continue;
2803        }
2804        content = true;
2805        break;
2806    }
2807    !content
2808}
2809
2810/// Remove HTML comments and collapsed `<details>` blocks.
2811fn strip_blocks(body: &str) -> String {
2812    let mut out = String::with_capacity(body.len());
2813    let mut rest = body;
2814    loop {
2815        let open = ["<!--", "<details>"]
2816            .iter()
2817            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
2818            .min_by_key(|(i, _)| *i);
2819        let Some((at, tag)) = open else {
2820            out.push_str(rest);
2821            return out;
2822        };
2823        out.push_str(&rest[..at]);
2824        let after = &rest[at + tag.len()..];
2825        let close = if tag == "<!--" { "-->" } else { "</details>" };
2826        match after.find(close) {
2827            Some(end) => rest = &after[end + close.len()..],
2828            // Unterminated: the rest of the body is inside the block.
2829            None => return out,
2830        }
2831    }
2832}
2833
2834/// Strip blockquote markers, which both bots wrap their callouts in.
2835fn unquote(line: &str) -> &str {
2836    let mut s = line.trim();
2837    while let Some(rest) = s.strip_prefix('>') {
2838        s = rest.trim_start();
2839    }
2840    s.trim()
2841}
2842
2843/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
2844fn is_checklist(line: &str) -> bool {
2845    let rest = line
2846        .strip_prefix("- ")
2847        .or_else(|| line.strip_prefix("* "))
2848        .unwrap_or("");
2849    let rest = rest.trim_start();
2850    matches!(
2851        rest.get(..3),
2852        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
2853    )
2854}
2855
2856/// A heading, a horizontal rule, or a callout tag - shape, never content.
2857fn is_decoration(line: &str) -> bool {
2858    line.starts_with('#')
2859        || line.starts_with("[!")
2860        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
2861}
2862
2863/// A line that is nothing but emphasis and links.
2864///
2865/// Both review jobs open with a status banner
2866/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
2867/// to a line-based test and asks for nothing, so it is measured the same way a
2868/// heading is: strip the markup, and if no word survives, it was decoration.
2869fn is_banner(line: &str) -> bool {
2870    let plain = drop_spans(line, "**", "**");
2871    let plain = if plain.contains("](") {
2872        drop_spans(&plain, "[", ")")
2873    } else {
2874        plain
2875    };
2876    !plain.chars().any(char::is_alphanumeric)
2877}
2878
2879/// Remove every `open` .. `close` span, including the delimiters. An
2880/// unterminated span swallows the rest of the input, which is what a reader
2881/// sees too.
2882fn drop_spans(s: &str, open: &str, close: &str) -> String {
2883    let mut out = String::with_capacity(s.len());
2884    let mut rest = s;
2885    while let Some(at) = rest.find(open) {
2886        out.push_str(&rest[..at]);
2887        let after = &rest[at + open.len()..];
2888        match after.find(close) {
2889            Some(end) => rest = &after[end + close.len()..],
2890            None => return out,
2891        }
2892    }
2893    out.push_str(rest);
2894    out
2895}
2896
2897/// The lock that keeps at most one run per repository actually moving the
2898/// base branch at a time: a rebase push, or `gh pr merge`.
2899///
2900/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
2901/// running a fix round in the winner's own worktree, waiting on the owner's
2902/// approval - touches nothing a *different* run in the same repository could
2903/// collide with, and holding a lock across any of that would serialise one
2904/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
2905/// resume, which is precisely the "must not wait on another task" property
2906/// the daemon's slot-freeing exists to give a resume. Only the two moments
2907/// that actually write to the shared base branch need mutual exclusion, and
2908/// both are brief.
2909///
2910/// One entry per repository, each its own `tokio::sync::Mutex`, so two
2911/// different repositories' runs never wait on each other. The outer
2912/// `std::sync::Mutex` guards only the map itself, held long enough to find or
2913/// insert an entry and clone its `Arc`, never across an `.await`.
2914fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
2915    static LOCKS: std::sync::LazyLock<
2916        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
2917    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
2918    LOCKS
2919        .lock()
2920        .unwrap_or_else(std::sync::PoisonError::into_inner)
2921        .entry(repo.to_path_buf())
2922        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
2923        .clone()
2924}
2925
2926/// `owner/repo` out of a pull request url, falling back to the checkout's
2927/// directory name when the url is not the usual `host/owner/repo/pull/N`.
2928fn repo_label(repo: &Path, pr_url: &str) -> String {
2929    let parts: Vec<&str> = pr_url.split('/').collect();
2930    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
2931        && at >= 2
2932        && !parts[at - 1].is_empty()
2933        && !parts[at - 2].is_empty()
2934    {
2935        return format!("{}/{}", parts[at - 2], parts[at - 1]);
2936    }
2937    repo.file_name()
2938        .map(|n| n.to_string_lossy().into_owned())
2939        .unwrap_or_default()
2940}
2941
2942/// The operator-facing sentence for a merge that went ahead with red checks,
2943/// or `None` when the checks were not red. Judged on `checks`, not on
2944/// `failing`, which can be non-empty on a green observation.
2945fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
2946    (pr.checks == Checks::Red).then(|| {
2947        format!(
2948            "Merged {repo_name} PR #{} with red checks: {} ({})",
2949            pr.number,
2950            if pr.failing.is_empty() {
2951                "(none named)".to_owned()
2952            } else {
2953                pr.failing.join(", ")
2954            },
2955            pr.url
2956        )
2957    })
2958}
2959
2960/// After a merge that succeeded: record which checks were red and tell the
2961/// operator. The decision to merge is already made; this only makes it audible.
2962/// Best-effort - a broken notifier never fails the run.
2963async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
2964    let repo_name = repo_label(&state.repo, &pr.url);
2965    let Some(summary) = red_merge_summary(&repo_name, pr) else {
2966        return;
2967    };
2968    if let Some(rec) = state.pr.as_mut() {
2969        rec.red_at_merge = pr.failing.clone();
2970    }
2971    state.event("land", summary.clone());
2972    // The notice pages through `[notify]` itself, once, unless a question
2973    // already carries the cause.
2974    crate::notices::raise_with(
2975        crate::notices::merged_red(&state.id, &summary),
2976        &state.config.notify,
2977    );
2978}
2979
2980/// Run the loop against a real pull request until it merges or the budget runs
2981/// out.
2982///
2983/// The caller decides whether landing happens at all: this is only reached when
2984/// `graph.land` is on. Returns the last observation, so the caller can report
2985/// what magi was looking at when it stopped.
2986pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
2987    land_with(state, pr_url, &GhForge).await
2988}
2989
2990/// The forge calls whose timing the loop's decisions depend on, behind a seam
2991/// so a test can script what the pull request looks like from one observation
2992/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
2993trait Forge {
2994    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
2995    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
2996    async fn poll(&self);
2997    /// Rerun the failed jobs of one workflow run.
2998    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
2999    /// Unix seconds, behind the seam so a test owns the clock.
3000    fn now(&self) -> i64;
3001    /// The check contexts the base branch requires, for a stop reason only.
3002    /// `None` when they could not be read, which is not the same as none.
3003    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3004    #[allow(clippy::too_many_arguments)]
3005    async fn fix(
3006        &self,
3007        state: &mut RunState,
3008        pr: &PrState,
3009        round: usize,
3010        budget: usize,
3011        reason: &str,
3012        logs: &str,
3013    ) -> Result<Fixed>;
3014}
3015
3016struct GhForge;
3017
3018impl Forge for GhForge {
3019    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3020        observe(repo, pr_url).await
3021    }
3022    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3023        gh(repo, argv).await
3024    }
3025    async fn poll(&self) {
3026        tokio::time::sleep(POLL).await;
3027    }
3028    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3029        let args = [
3030            "run".to_owned(),
3031            "rerun".to_owned(),
3032            run.to_owned(),
3033            "--failed".to_owned(),
3034        ];
3035        let (ok, out) = gh(repo, &args).await?;
3036        if !ok {
3037            bail!("gh run rerun {run}: {out}");
3038        }
3039        Ok(())
3040    }
3041    fn now(&self) -> i64 {
3042        jiff::Timestamp::now().as_second()
3043    }
3044    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3045        required_contexts_of(repo, base).await
3046    }
3047    async fn fix(
3048        &self,
3049        state: &mut RunState,
3050        pr: &PrState,
3051        round: usize,
3052        budget: usize,
3053        reason: &str,
3054        logs: &str,
3055    ) -> Result<Fixed> {
3056        fix_round(state, pr, round, budget, reason, logs).await
3057    }
3058}
3059
3060/// Percent-encode a branch name for a URL path segment (`/` included).
3061fn encode_path_segment(s: &str) -> String {
3062    let mut out = String::new();
3063    for b in s.bytes() {
3064        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3065            out.push(b as char);
3066        } else {
3067            let _ = write!(out, "%{b:02X}");
3068        }
3069    }
3070    out
3071}
3072
3073/// Read the required contexts of `base` from classic branch protection and
3074/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3075/// these two endpoints do not list are missed. Any unreadable source makes the
3076/// whole answer `None`: a partial set would read as a complete one.
3077async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3078    let enc = encode_path_segment(base);
3079    let mut all = BTreeSet::new();
3080    // Classic protection answers 404 for an unprotected branch, which is
3081    // "nothing required here", not a failure to read.
3082    let classic = gh(
3083        repo,
3084        &[
3085            "api".to_owned(),
3086            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3087        ],
3088    )
3089    .await
3090    .ok()?;
3091    if classic.0 {
3092        all.extend(parse_classic_required(&classic.1)?);
3093    } else if !classic.1.contains("404") {
3094        return None;
3095    }
3096    let rules = gh(
3097        repo,
3098        &[
3099            "api".to_owned(),
3100            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3101        ],
3102    )
3103    .await
3104    .ok()?;
3105    if !rules.0 {
3106        return None;
3107    }
3108    all.extend(parse_ruleset_required(&rules.1)?);
3109    Some(all)
3110}
3111
3112/// `required_status_checks` of classic protection: `contexts` plus the
3113/// `checks[].context` form.
3114fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3115    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3116    let mut out = BTreeSet::new();
3117    for c in v.get("contexts")?.as_array()? {
3118        out.insert(c.as_str()?.to_owned());
3119    }
3120    for c in v
3121        .get("checks")
3122        .and_then(|c| c.as_array())
3123        .into_iter()
3124        .flatten()
3125    {
3126        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3127            out.insert(name.to_owned());
3128        }
3129    }
3130    Some(out)
3131}
3132
3133/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3134fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3135    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3136    let mut out = BTreeSet::new();
3137    for rule in v.as_array()? {
3138        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3139            continue;
3140        }
3141        let checks = rule
3142            .pointer("/parameters/required_status_checks")?
3143            .as_array()?;
3144        for c in checks {
3145            out.insert(c.get("context")?.as_str()?.to_owned());
3146        }
3147    }
3148    Some(out)
3149}
3150
3151/// Is the observation older than the commit a fix round pushed?
3152///
3153/// The forge takes a few seconds to move the pull request to a new head and
3154/// attach that head's check runs, and until it has, the rollup is the previous
3155/// head's - all green, which is exactly what a merge decision must not read.
3156/// An absent or different head counts as not yet: guessing "close enough"
3157/// would reopen the hole.
3158fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3159    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3160}
3161
3162/// The commit an observation may be decided on, or `None` while it cannot be
3163/// trusted to describe one.
3164///
3165/// `None` when a pushed commit is awaited and the pull request is not on it,
3166/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3167/// last commit's) is not the head's: that is the previous commit's checks. The
3168/// caller re-polls on `None`. A rollup that cannot be read (including one
3169/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3170/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3171fn bound_head<'a>(
3172    seen_head: &'a str,
3173    rollup_head: &str,
3174    awaiting: Option<&str>,
3175) -> Option<&'a str> {
3176    if seen_head.is_empty()
3177        || awaiting_new_head(awaiting, seen_head)
3178        || !rollup_head.eq_ignore_ascii_case(seen_head)
3179    {
3180        return None;
3181    }
3182    Some(seen_head)
3183}
3184
3185/// What a refused `gh pr merge` means.
3186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3187enum Refused {
3188    /// The branch policy is not satisfied *yet*: go back to waiting.
3189    Pending,
3190    /// Checks have settled and the merge state still says no, seen for the
3191    /// first time. The forge updates `mergeStateStatus` a moment after the last
3192    /// check finishes, so one more look is allowed before believing it.
3193    Recheck,
3194    /// Nothing is in flight and the policy still refuses: a person has to
3195    /// supply what it asks for (a review, say).
3196    Final,
3197}
3198
3199/// Judged from the pull request's state after the refusal, never from the
3200/// refusal's wording, which belongs to the forge and changes.
3201fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3202    let Some(after) = after else {
3203        // Unreadable is not evidence of anything; the next loop reads again.
3204        return Refused::Pending;
3205    };
3206    if after.pr.state != PrLifecycle::Open {
3207        return Refused::Final;
3208    }
3209    // The branch moved after it was observed (the forge refuses a merge pinned
3210    // to the old commit): not a verdict on anything, look again.
3211    if !after.head.eq_ignore_ascii_case(observed_head) {
3212        return Refused::Pending;
3213    }
3214    // The same binding the loop applies: checks of another commit say nothing.
3215    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3216        return Refused::Pending;
3217    }
3218    let state = after.merge_state.to_ascii_uppercase();
3219    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3220        || state.is_empty()
3221        || state == "UNKNOWN"
3222    {
3223        return Refused::Pending;
3224    }
3225    if rechecked {
3226        Refused::Final
3227    } else {
3228        Refused::Recheck
3229    }
3230}
3231
3232/// Take auto-merge back from the forge and forget that it was armed.
3233///
3234/// `Err` carries the forge's message: the caller must not push or move on, as
3235/// an armed merge left behind could still fire for a commit nobody approved.
3236async fn disarm<F: Forge>(
3237    forge: &F,
3238    state: &mut RunState,
3239    repo: &Path,
3240    number: u64,
3241) -> std::result::Result<(), String> {
3242    let argv = disable_automerge_argv(number);
3243    let out = {
3244        let merge_lock = repo_merge_lock(repo);
3245        let _merge_slot = merge_lock.lock().await;
3246        forge.merge(repo, &argv).await
3247    };
3248    match out {
3249        Ok((true, _)) => {
3250            state.land_armed_head = None;
3251            state.event("land", "auto-merge disabled");
3252            state.save().map_err(|e| format!("{e:#}"))?;
3253            Ok(())
3254        }
3255        Ok((false, msg)) => Err(msg),
3256        Err(e) => Err(format!("{e:#}")),
3257    }
3258}
3259
3260/// [`stop`], first taking back an armed auto-merge so a pull request magi
3261/// gave up on does not merge on its own later. A failure to disarm is added
3262/// to the reason rather than hiding it.
3263async fn stop_disarmed<F: Forge>(
3264    forge: &F,
3265    state: &mut RunState,
3266    repo: &Path,
3267    pr: &PrState,
3268    why: &str,
3269) -> Result<()> {
3270    if state.land_armed_head.is_none() {
3271        return stop(state, repo, pr, why).await;
3272    }
3273    match disarm(forge, state, repo, pr.number).await {
3274        Ok(()) => stop(state, repo, pr, why).await,
3275        Err(e) => {
3276            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3277            stop(state, repo, pr, &why).await
3278        }
3279    }
3280}
3281
3282async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
3283    crate::pr_head::ensure_owner(state)?;
3284    let repo = state.repo.clone();
3285    let budget = state.config.graph.land_rounds;
3286    let mut round = 0usize;
3287    // Counted apart from `round`: a rebase is not a fix, and a base that
3288    // moved is not the change's fault.
3289    let mut rebases = 0usize;
3290    let mut waited = Duration::ZERO;
3291    // Comment bodies the fixer has already been shown. A comment is
3292    // outstanding until it has been handed over once; after that it is a
3293    // recorded decision, not an open question, and re-feeding it would loop the
3294    // budget away on a comment the fixer already declined with an argument.
3295    let mut shown: BTreeSet<String> = BTreeSet::new();
3296    // Await the last successfully published head, including across resumes.
3297    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
3298    // Whether a settled-checks refusal has already been given its one re-look.
3299    let mut rechecked = false;
3300
3301    // Marks the run resumable through exactly this function, not through a
3302    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
3303    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
3304    // status specifically to know a resumed run belongs back in `land`
3305    // rather than at a second `gh pr create`. Set on every entry - fresh or
3306    // resumed - because a resume that parked here again must keep reading
3307    // `Landing`, not whatever a first pass through `merge` left behind.
3308    state.status = RunStatus::Landing;
3309    state.event("land", format!("watching {pr_url}"));
3310    state.save()?;
3311
3312    // An armed merge recorded by an earlier pass may or may not have reached
3313    // the forge (the record is written before the call). It is taken back on
3314    // the first observation, where a pull request is known to stop with.
3315    let mut resumed_armed = state.land_armed_head.is_some();
3316
3317    loop {
3318        let seen = forge.view(&repo, pr_url).await?;
3319        let mut pr = seen.pr.clone();
3320        pr.review_comments.retain(|c| !shown.contains(&c.body));
3321        state.pr = Some(crate::run::PrRecord {
3322            url: pr.url.clone(),
3323            number: pr.number,
3324            state: pr.state.as_str().to_owned(),
3325            checks: pr.checks.as_str().to_owned(),
3326            round,
3327            rounds: budget,
3328            red_at_merge: Vec::new(),
3329        });
3330        state.save()?;
3331
3332        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
3333            // An approval already given for the same head is reused, so
3334            // nothing is asked twice. A failed disable
3335            // stops the run with the record kept: pushing on could change the
3336            // head under an arm that is still live.
3337            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3338                let why = format!(
3339                    "a previous pass may have armed auto-merge and it could not be disabled \
3340                     on resume: {e}"
3341                );
3342                stop(state, &repo, &pr, &why).await?;
3343                return Ok(pr);
3344            }
3345        }
3346
3347        // The head moved away from the one auto-merge was armed on, by
3348        // someone other than this loop. The arm is pinned and would refuse to
3349        // merge the new commit, but the approval was for the old one: take it
3350        // back and go through the normal path again.
3351        if pr.state == PrLifecycle::Open
3352            && !seen.head.is_empty()
3353            && state
3354                .land_armed_head
3355                .as_deref()
3356                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
3357        {
3358            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3359                let why = format!(
3360                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
3361                );
3362                stop(state, &repo, &pr, &why).await?;
3363                return Ok(pr);
3364            }
3365        }
3366
3367        if pr.state == PrLifecycle::Open {
3368            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
3369                if waited >= WAIT_CEILING {
3370                    let want = awaiting_head.as_deref().unwrap_or_default();
3371                    let why = format!(
3372                        "the pull request's checks were still not about one readable head after \
3373                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
3374                         someone may have pushed over it",
3375                        WAIT_CEILING.as_secs() / 60,
3376                        if want.is_empty() { "any" } else { want },
3377                        if seen.head.is_empty() {
3378                            "nothing readable"
3379                        } else {
3380                            &seen.head
3381                        },
3382                        if seen.rollup_head.is_empty() {
3383                            "nothing readable"
3384                        } else {
3385                            &seen.rollup_head
3386                        },
3387                    );
3388                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
3389                    return Ok(pr);
3390                }
3391                waited += POLL;
3392                forge.poll().await;
3393                continue;
3394            }
3395            // Reset once, when the awaited head first shows up; resetting on
3396            // every re-observation would let a standing refusal wait forever.
3397            if awaiting_head.take().is_some() {
3398                // The checks now being read belong to the new head; give them
3399                // the same grace a fresh pull request gets.
3400                waited = Duration::ZERO;
3401            }
3402        }
3403
3404        // A failed workflow run is rerun once, judged by its own run rather
3405        // than by the rollup's aggregate: another run still pending, or a fix
3406        // budget already spent, must not keep a flaky failure from its one
3407        // rerun. It spends no round and no attempt.
3408        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
3409            let failing = rerunnable(&seen.contexts);
3410            let now = forge.now();
3411            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
3412                RerunStep::Rerun(runs) => {
3413                    let rec = state.land_reruns.get_or_insert_with(Default::default);
3414                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
3415                        *rec = LandReruns {
3416                            head: seen.head.clone(),
3417                            runs: BTreeMap::new(),
3418                        };
3419                    }
3420                    for run in &runs {
3421                        rec.runs.insert(run.clone(), now);
3422                    }
3423                    // Durable before the call: a restart must not
3424                    // rerun the same run twice. A save that fails
3425                    // means no rerun.
3426                    state.save()?;
3427                    let mut any = false;
3428                    for run in &runs {
3429                        let names = failing
3430                            .iter()
3431                            .filter(|(_, r)| r.as_deref() == Some(run))
3432                            .map(|(l, _)| l.as_str())
3433                            .collect::<Vec<_>>()
3434                            .join(", ");
3435                        match forge.rerun(&repo, run).await {
3436                            Ok(()) => {
3437                                any = true;
3438                                state.event(
3439                                    "land",
3440                                    format!("reran failed check(s) {names} (run {run})"),
3441                                );
3442                            }
3443                            // Stays recorded: never a retry loop.
3444                            Err(e) => state.event(
3445                                "land",
3446                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
3447                            ),
3448                        }
3449                    }
3450                    state.save()?;
3451                    if any {
3452                        waited = Duration::ZERO;
3453                        forge.poll().await;
3454                        continue;
3455                    }
3456                }
3457                RerunStep::Grace => {
3458                    if waited >= WAIT_CEILING {
3459                        let why = format!(
3460                            "checks were still failing after a rerun and {} minutes",
3461                            WAIT_CEILING.as_secs() / 60
3462                        );
3463                        stop(state, &repo, &pr, &why).await?;
3464                        return Ok(pr);
3465                    }
3466                    waited += POLL;
3467                    forge.poll().await;
3468                    continue;
3469                }
3470                RerunStep::Fix => {}
3471            }
3472        }
3473
3474        let step = decide(&pr, round, budget, waited);
3475        // Armed on exactly this head: the forge is doing the waiting. A
3476        // decision to merge (or keep waiting) is only watched, never re-armed
3477        // and never re-approved; anything else - a red check, a comment, a
3478        // conflict - falls through to its own arm, which disarms first.
3479        let armed_here = state
3480            .land_armed_head
3481            .as_deref()
3482            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
3483        if armed_here && matches!(step, Step::Merge | Step::Wait) {
3484            if waited >= WAIT_CEILING {
3485                let required = if seen.base.is_empty() {
3486                    None
3487                } else {
3488                    forge.required_contexts(&repo, &seen.base).await
3489                };
3490                let why = format!(
3491                    "auto-merge was armed on {} but the pull request did not merge within {} \
3492                     minutes ({})",
3493                    seen.head,
3494                    WAIT_CEILING.as_secs() / 60,
3495                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
3496                );
3497                stop_disarmed(forge, state, &repo, &pr, &why).await?;
3498                return Ok(pr);
3499            }
3500            waited += POLL;
3501            forge.poll().await;
3502            continue;
3503        }
3504        if armed_here && !matches!(step, Step::Done { .. }) {
3505            // Not merging or waiting any more: whatever is next may change the
3506            // head or give up, and neither may leave the arm standing.
3507            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3508                let why = format!("auto-merge could not be disabled: {e}");
3509                stop(state, &repo, &pr, &why).await?;
3510                return Ok(pr);
3511            }
3512        }
3513        match step {
3514            Step::Wait => {
3515                if waited >= WAIT_CEILING {
3516                    let why = format!(
3517                        "checks were still running after {} minutes",
3518                        WAIT_CEILING.as_secs() / 60
3519                    );
3520                    stop(state, &repo, &pr, &why).await?;
3521                    return Ok(pr);
3522                }
3523                waited += POLL;
3524                forge.poll().await;
3525            }
3526            Step::Done { merged } => {
3527                // Auto-merge is pinned to the approved head, but the forge's
3528                // own handling of a later push is not something magi can
3529                // see: if the pull request merged on another commit, say so
3530                // loudly rather than record a clean landing.
3531                if let Some(armed) = state.land_armed_head.take() {
3532                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
3533                        let msg = format!(
3534                            "{} merged on {} but the owner approved {armed}; review what landed",
3535                            pr.url, seen.head
3536                        );
3537                        tracing::warn!("{msg}");
3538                        state.event("land", msg);
3539                        // Only an arm left by an older build can get here.
3540                        // The wording is fixed so a repeat does not relight
3541                        // the notice.
3542                        crate::notices::raise_with(
3543                            crate::notices::Notice::warn(
3544                                &format!("merged-unapproved-head:{}", state.id),
3545                                "A pull request merged on a commit the owner did not approve; \
3546                                 review what landed",
3547                            )
3548                            .link(crate::notices::Link::Run {
3549                                id: state.id.clone(),
3550                            }),
3551                            &state.config.notify,
3552                        );
3553                    }
3554                }
3555                state.status = if merged {
3556                    RunStatus::Merged
3557                } else {
3558                    RunStatus::Ready
3559                };
3560                let detail = if merged {
3561                    format!("{} was merged", pr.url)
3562                } else {
3563                    format!("{} was closed without merging", pr.url)
3564                };
3565                state.merge = Some(MergeOutcome {
3566                    mode: MergeMode::Pr,
3567                    ok: merged,
3568                    detail: detail.clone(),
3569                    empty: false,
3570                });
3571                state.event("land", detail);
3572                state.save()?;
3573                write_pr_state_through(state, pr.state);
3574                return Ok(pr);
3575            }
3576            Step::Merge => {
3577                let subject = merge_subject(
3578                    crate::graph::landing_title(state, &seen.title),
3579                    &crate::graph::landing_subject_source(state),
3580                );
3581                // The owner sees the panel before the one irreversible step,
3582                // and an unanswered question is a hold: silence never merges.
3583                //
3584                // `land_approval` asks about every merge. With it off, a
3585                // review hand-off the panel contested (a blocking finding
3586                // open and a reject vote) is asked about all the same.
3587                let contested = contested_to_ask(state);
3588                if state.config.graph.land_approval
3589                    || contested.is_some()
3590                    || repair_available(state)
3591                    || !state.merge_repairs.is_empty()
3592                {
3593                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
3594                        .await?
3595                    {
3596                        ApprovalGate::Approved => {}
3597                        ApprovalGate::Repair => {
3598                            // The driver consumes the saved request under the existing
3599                            // run/task claim. No merge, no second run or task.
3600                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
3601                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
3602                                state.save()?;
3603                            }
3604                            return Ok(pr);
3605                        }
3606                        ApprovalGate::Held => {
3607                            stop(
3608                                state,
3609                                &repo,
3610                                &pr,
3611                                "the owner did not approve the merge (held or unanswered)",
3612                            )
3613                            .await?;
3614                            return Ok(pr);
3615                        }
3616                        // Filed (or still standing from an earlier visit) and
3617                        // not yet answered. Park here rather than wait: the
3618                        // question survives on disk, the daemon hands this
3619                        // run's slot to something else, and a later resume
3620                        // re-enters `land`, finds the same question, and
3621                        // either merges or stops depending on what it says
3622                        // by then.
3623                        ApprovalGate::Pending => {
3624                            state.end_pass_parked(crate::run::ParkCause::Question);
3625                            state.parked = true;
3626                            state.event(
3627                                "land",
3628                                "parked awaiting merge approval - resumes once answered",
3629                            );
3630                            state.save()?;
3631                            return Ok(pr);
3632                        }
3633                    }
3634                }
3635                // Open and past the gate above, so `seen.head` is the commit
3636                // the checks were bound to and the owner approved.
3637                //
3638                // Merge directly, bound to that commit. Auto-merge is not
3639                // armed any more: the forge checks `--match-head-commit` only
3640                // when the request is made, so an arm outlives the head it
3641                // was made for, and a push of another commit whose
3642                // requirements are met first would merge without approval.
3643                // A direct merge is checked by the forge at the moment it
3644                // happens, so only the approved head can land.
3645                let observed_head = seen.head.clone();
3646                // Read the pull request again just before: the state seen
3647                // above can be a moment old.
3648                {
3649                    let fresh = forge.view(&repo, pr_url).await.ok();
3650                    if !direct_merge_is_safe(
3651                        fresh.as_ref(),
3652                        &observed_head,
3653                        &shown,
3654                        round,
3655                        budget,
3656                        waited,
3657                    ) {
3658                        if waited >= WAIT_CEILING {
3659                            let why = "the pull request did not settle on the approved head \
3660                                       before it could be merged";
3661                            stop(state, &repo, &pr, why).await?;
3662                            return Ok(pr);
3663                        }
3664                        state.event(
3665                            "land",
3666                            "the pull request changed before merging; looking again",
3667                        );
3668                        waited += POLL;
3669                        forge.poll().await;
3670                        continue;
3671                    }
3672                }
3673                let argv = merge_argv_at(pr.number, &subject, &observed_head);
3674                let out = {
3675                    let merge_lock = repo_merge_lock(&repo);
3676                    let _merge_slot = merge_lock.lock().await;
3677                    forge.merge(&repo, &argv).await?
3678                };
3679                if out.0 {
3680                    // Exit 0 is not proof of a merge: with a merge queue `gh`
3681                    // enqueues (or reports the pull request already queued)
3682                    // and succeeds while it is still open. Ask the forge; an
3683                    // unreadable answer is not a confirmation either, so it
3684                    // is looked at again rather than recorded as merged.
3685                    let confirmed = forge
3686                        .view(&repo, pr_url)
3687                        .await
3688                        .is_ok_and(|c| c.pr.state == PrLifecycle::Merged);
3689                    if !confirmed {
3690                        if waited >= WAIT_CEILING {
3691                            let why = "the merge request succeeded but the pull request \
3692                                       could not be confirmed merged after waiting";
3693                            stop(state, &repo, &pr, why).await?;
3694                            return Ok(pr);
3695                        }
3696                        state.event(
3697                            "land",
3698                            "merge accepted but the pull request is not confirmed merged yet; waiting",
3699                        );
3700                        state.save()?;
3701                        waited += POLL;
3702                        forge.poll().await;
3703                        continue;
3704                    }
3705                    pr.state = PrLifecycle::Merged;
3706                    state.status = RunStatus::Merged;
3707                    state.merge = Some(MergeOutcome {
3708                        mode: MergeMode::Pr,
3709                        ok: true,
3710                        detail: format!("gh {}", argv.join(" ")),
3711                        empty: false,
3712                    });
3713                    // The last `state.pr` snapshot is whatever the poll before
3714                    // this merge observed - still `open` - and nothing below
3715                    // refreshes it from GitHub again, so the UI's round rail
3716                    // would otherwise keep animating a merged run forever.
3717                    if let Some(pr_record) = state.pr.as_mut() {
3718                        pr_record.state = pr.state.as_str().to_owned();
3719                    }
3720                    state.event("land", format!("merged {} as `{subject}`", pr.url));
3721                    announce_red_merge(state, &pr).await;
3722                    state.save()?;
3723                    write_pr_state_through(state, pr.state);
3724                    return Ok(pr);
3725                }
3726                let after_seen = forge.view(&repo, pr_url).await.ok();
3727                let after = after_seen.as_ref().map(|s| s.pr.state);
3728                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
3729                    pr.state = PrLifecycle::Merged;
3730                    state.status = RunStatus::Merged;
3731                    state.merge = Some(outcome);
3732                    if let Some(pr_record) = state.pr.as_mut() {
3733                        pr_record.state = pr.state.as_str().to_owned();
3734                    }
3735                    state.event("land", format!("merged {} as `{subject}`", pr.url));
3736                    announce_red_merge(state, &pr).await;
3737                    state.save()?;
3738                    write_pr_state_through(state, pr.state);
3739                    return Ok(pr);
3740                }
3741                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
3742                match verdict {
3743                    Refused::Final => {
3744                        let merge_state = after_seen
3745                            .as_ref()
3746                            .map(|s| s.merge_state.as_str())
3747                            .filter(|m| !m.is_empty())
3748                            .unwrap_or("unknown");
3749                        // Which refusal this was decides what a person has to
3750                        // do about it, so the two are worded apart; both carry
3751                        // the forge's own message.
3752                        let why = format!(
3753                            "the merge was refused: {} (merge state: {merge_state})",
3754                            out.1
3755                        );
3756                        stop(state, &repo, &pr, &why).await?;
3757                        return Ok(pr);
3758                    }
3759                    verdict => {
3760                        // Back to the top, which re-decides and passes the
3761                        // approval gate again, a poll later. `waited` is not
3762                        // reset here: only a pushed fix restarts it, or a
3763                        // standing refusal would wait forever.
3764                        if waited >= WAIT_CEILING {
3765                            let why = format!(
3766                                "the merge was still refused after {} minutes: {}",
3767                                WAIT_CEILING.as_secs() / 60,
3768                                out.1
3769                            );
3770                            stop(state, &repo, &pr, &why).await?;
3771                            return Ok(pr);
3772                        }
3773                        if verdict == Refused::Recheck {
3774                            rechecked = true;
3775                        }
3776                        state.event(
3777                            "land",
3778                            "merge refused while the branch policy is not satisfied yet; waiting",
3779                        );
3780                        state.save()?;
3781                        waited += POLL;
3782                        forge.poll().await;
3783                    }
3784                }
3785            }
3786            Step::Rebase => {
3787                // Bounded by the same budget as a fix, because a rebase that
3788                // keeps being needed means the base moves faster than this
3789                // run can land and a person should decide what to do. It
3790                // spends none of that budget: the change is not what is
3791                // wrong.
3792                if rebases >= budget {
3793                    let why = format!(
3794                        "the base moved under this branch {budget} time(s) and it still does \
3795                         not merge; rebasing again would only race it"
3796                    );
3797                    stop(state, &repo, &pr, &why).await?;
3798                    return Ok(pr);
3799                }
3800                rebases += 1;
3801                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
3802                    stop(
3803                        state,
3804                        &repo,
3805                        &pr,
3806                        "the pull request conflicts and this run has no winning branch to rebase",
3807                    )
3808                    .await?;
3809                    return Ok(pr);
3810                };
3811                let base = state.base_branch.clone();
3812                state.event(
3813                    "land",
3814                    format!("{} no longer merges; rebasing onto {base}", pr.url),
3815                );
3816                state.save()?;
3817
3818                // Onto the base as the *remote* has it: the local ref may be
3819                // behind, and rebasing onto a stale base produces a branch
3820                // that conflicts all over again.
3821                let remote = state.config.merge.remote.clone();
3822                git::fetch(&repo, &remote, &base).await.ok();
3823                let scratch = state.dir().join("rebase");
3824                let onto = format!("{remote}/{base}");
3825                let rebased =
3826                    match crate::rebase::rebase_with_fixer(state, &scratch, &branch, &onto).await {
3827                        Ok(crate::rebase::Rebased::Applied) => Ok(None),
3828                        Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3829                        Err(e) => Err(e),
3830                    };
3831                match rebased {
3832                    Ok(None) => {
3833                        let pushed = {
3834                            let merge_lock = repo_merge_lock(&repo);
3835                            let _merge_slot = merge_lock.lock().await;
3836                            if let Some(head) = &state.pr_head {
3837                                git::push_as(
3838                                    &repo,
3839                                    &state.config.merge.remote,
3840                                    &branch,
3841                                    head,
3842                                    state
3843                                        .pr_pushed_head
3844                                        .as_deref()
3845                                        .context("missing public head lease")?,
3846                                )
3847                                .await?
3848                            } else {
3849                                git::push_pinned(
3850                                    &repo,
3851                                    &state.config.merge.remote,
3852                                    &branch,
3853                                    &seen.head,
3854                                )
3855                                .await?
3856                            }
3857                        };
3858                        if !pushed.ok() {
3859                            let why = format!(
3860                                "rebased {branch} but could not push it: {}",
3861                                pushed.stderr.trim()
3862                            );
3863                            stop(state, &repo, &pr, &why).await?;
3864                            return Ok(pr);
3865                        }
3866                        // Bind to what was pushed: until the pull request is
3867                        // seen on it, the rollup is the old head's.
3868                        let head =
3869                            match git::rev_parse(&repo, &format!("refs/heads/{branch}")).await {
3870                                Ok(head) => head,
3871                                Err(e) => {
3872                                    let why = format!(
3873                                        "rebased and pushed {branch} but could not read the pushed \
3874                                     commit: {e:#}"
3875                                    );
3876                                    stop(state, &repo, &pr, &why).await?;
3877                                    return Ok(pr);
3878                                }
3879                            };
3880                        crate::graph::refresh_reviewed_commits(state, &branch).await;
3881                        state.pr_pushed_head = Some(head.clone());
3882                        awaiting_head = Some(head);
3883                        rechecked = false;
3884                        state.event("land", format!("rebased {branch} onto {base}"));
3885                        state.save()?;
3886                        // The forge has to re-run its checks against the
3887                        // rebased head before anything else can be decided.
3888                        waited = Duration::ZERO;
3889                        tokio::time::sleep(POLL).await;
3890                    }
3891                    // The fixer's rounds are spent (or it could not finish):
3892                    // that is a decision for a person.
3893                    Ok(Some(conflict)) => {
3894                        let why = format!(
3895                            "{} conflicts with {base} and the rebase did not apply: {}",
3896                            pr.url,
3897                            conflict.chars().take(600).collect::<String>()
3898                        );
3899                        stop(state, &repo, &pr, &why).await?;
3900                        return Ok(pr);
3901                    }
3902                    Err(e) => {
3903                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
3904                        stop(state, &repo, &pr, &why).await?;
3905                        return Ok(pr);
3906                    }
3907                }
3908            }
3909            Step::GiveUp { reason } => {
3910                stop(state, &repo, &pr, &reason).await?;
3911                return Ok(pr);
3912            }
3913            Step::Fix { reason } => {
3914                round += 1;
3915                waited = Duration::ZERO;
3916                for c in &pr.review_comments {
3917                    shown.insert(c.body.clone());
3918                }
3919                state.event("land", format!("round {round}: {reason}"));
3920                state.save()?;
3921
3922                let logs = failing_logs(&repo, &seen.failing_urls).await;
3923                let was_red = pr.checks == Checks::Red;
3924                match forge.fix(state, &pr, round, budget, &reason, &logs).await? {
3925                    Fixed::Committed { head } => {
3926                        // Whatever the next look shows may still be the
3927                        // previous head; see `awaiting_new_head`.
3928                        state.pr_pushed_head = Some(head.clone());
3929                        awaiting_head = Some(head);
3930                        rechecked = false;
3931                        waited = Duration::ZERO;
3932                        forge.poll().await;
3933                    }
3934                    Fixed::Declined if was_red => {
3935                        let why = format!(
3936                            "the fixer produced no commit while {} check(s) were failing \
3937                             ({}); stopping instead of looping on an unchanged tree",
3938                            pr.failing.len(),
3939                            pr.failing.join(", ")
3940                        );
3941                        stop(state, &repo, &pr, &why).await?;
3942                        return Ok(pr);
3943                    }
3944                    // Comment-driven round with no commit: the fixer read the
3945                    // comments and changed nothing, which is a decision it is
3946                    // allowed to make. The comments are recorded as shown, so
3947                    // the next observation sees a clean pull request.
3948                    Fixed::Declined => state.event(
3949                        "land",
3950                        format!("round {round}: fixer declined the comments, nothing committed"),
3951                    ),
3952                    Fixed::Failed(why) => {
3953                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
3954                        return Ok(pr);
3955                    }
3956                }
3957                state.save()?;
3958            }
3959        }
3960    }
3961}
3962
3963/// One observation, plus the two things [`PrState`] deliberately does not carry:
3964/// the title (needed for the squash subject) and where the failing checks'
3965/// logs live.
3966#[derive(Clone)]
3967struct Seen {
3968    pr: PrState,
3969    title: String,
3970    failing_urls: Vec<(String, String)>,
3971    /// `headRefOid`: the commit this observation, checks included, is about.
3972    head: String,
3973    /// The commit the checks belong to, read from the same GraphQL node as
3974    /// the checks themselves. Empty when unreadable.
3975    rollup_head: String,
3976    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
3977    /// BEHIND and DRAFT together, and a stop reason has to say which.
3978    merge_state: String,
3979    /// Every check of the rollup, for naming what an armed merge waits on.
3980    contexts: Vec<CheckInfo>,
3981    /// `baseRefName`, to look up which contexts the base requires.
3982    base: String,
3983}
3984
3985/// One check of the rollup as far as a stop reason needs it.
3986#[derive(Clone)]
3987struct CheckInfo {
3988    label: String,
3989    verdict: Verdict,
3990    required: Option<bool>,
3991    /// The workflow run the check belongs to; `None` for an external status.
3992    run: Option<String>,
3993}
3994
3995/// What to do with a red head before a fix round is spent on it.
3996#[derive(Debug, Clone, PartialEq, Eq)]
3997enum RerunStep {
3998    /// Rerun these workflow runs (each not yet rerun for this head).
3999    Rerun(Vec<String>),
4000    /// Reran already and the forge may still report the old failure: wait.
4001    Grace,
4002    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4003    Fix,
4004}
4005
4006/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4007/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4008/// pending checks in another run do not delay this run's rerun.
4009fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4010    contexts
4011        .iter()
4012        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4013        // `gh run rerun --failed` refuses a run with a job still going.
4014        .filter(|c| {
4015            c.run.as_ref().is_none_or(|r| {
4016                !contexts.iter().any(|o| {
4017                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4018                        && o.run.as_ref() == Some(r)
4019                })
4020            })
4021        })
4022        .map(|c| (c.label.clone(), c.run.clone()))
4023        .collect()
4024}
4025
4026/// Pure: rerun once per run id, wait out the grace, then hand over.
4027fn rerun_step(
4028    failing: &[(String, Option<String>)],
4029    record: Option<&LandReruns>,
4030    head: &str,
4031    now: i64,
4032) -> RerunStep {
4033    // A failure with no workflow run (an external status) cannot be rerun, and
4034    // the fixer's push re-runs everything anyway.
4035    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4036        return RerunStep::Fix;
4037    }
4038    // Reruns belong to the head they were made on.
4039    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4040    let mut fresh: Vec<String> = Vec::new();
4041    let mut recent = false;
4042    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4043        match record.and_then(|r| r.runs.get(run)) {
4044            None => {
4045                if !fresh.iter().any(|f| f == run) {
4046                    fresh.push(run.to_owned());
4047                }
4048            }
4049            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4050            Some(_) => {}
4051        }
4052    }
4053    if !fresh.is_empty() {
4054        RerunStep::Rerun(fresh)
4055    } else if recent {
4056        RerunStep::Grace
4057    } else {
4058        RerunStep::Fix
4059    }
4060}
4061
4062/// Read the pull request: `gh pr view` for the top-level thread and merge
4063/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
4064/// inline review comments `gh pr view` does not report.
4065async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4066    let view = gh(
4067        repo,
4068        &[
4069            "pr".to_owned(),
4070            "view".to_owned(),
4071            pr_url.to_owned(),
4072            "--json".to_owned(),
4073            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4074                .to_owned(),
4075        ],
4076    )
4077    .await?;
4078    if !view.0 {
4079        bail!("gh pr view {pr_url}: {}", view.1);
4080    }
4081    let number = parse_pr(&view.1)?.number;
4082    let node = last_commit_node(repo, number).await;
4083    let mut seen = seen_from(&view.1, node.as_deref())?;
4084
4085    let inline = gh(
4086        repo,
4087        &[
4088            "api".to_owned(),
4089            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4090        ],
4091    )
4092    .await?;
4093    if inline.0 {
4094        match parse_inline_comments(&inline.1) {
4095            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
4096            // An unreadable inline thread must not end a landing: the rollup
4097            // and the top-level thread are still real signal.
4098            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
4099        }
4100    } else {
4101        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
4102    }
4103    Ok(seen)
4104}
4105
4106/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
4107/// response. No I/O.
4108///
4109/// The commit oid and the checks are read from the *same* node, so the rollup
4110/// is bound to the commit it belongs to by construction; two reads that agree
4111/// before and after another response prove nothing about what that response
4112/// held. The view's own rollup is never used. A node that is missing,
4113/// unreadable, carries GraphQL errors, or whose checks run past the page
4114/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
4115/// as "look again" and never as a reason to merge.
4116fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
4117    let mut pr = parse_pr(view_json)?;
4118    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
4119
4120    let mut rollup_head = String::new();
4121    let mut failing_urls = Vec::new();
4122    let mut contexts = Vec::new();
4123    let mut checks = Checks::Unknown;
4124    let mut failing = Vec::new();
4125    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
4126        (checks, failing) = rollup_verdict(&rollup);
4127        failing_urls = rollup
4128            .iter()
4129            .filter(|c| c.verdict() == Verdict::Fail)
4130            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
4131            .collect();
4132        contexts = rollup
4133            .iter()
4134            .map(|c| CheckInfo {
4135                label: c.label(),
4136                verdict: c.verdict(),
4137                required: c.is_required,
4138                run: c.url().and_then(run_of),
4139            })
4140            .collect();
4141        rollup_head = oid;
4142    }
4143    pr.checks = checks;
4144    pr.failing = failing;
4145
4146    Ok(Seen {
4147        pr,
4148        title: raw.title,
4149        failing_urls,
4150        head: raw.head_ref_oid,
4151        rollup_head,
4152        merge_state: raw.merge_state_status,
4153        contexts,
4154        base: raw.base_ref_name,
4155    })
4156}
4157
4158/// The last commit's oid and its checks from one GraphQL response, `None`
4159/// when anything about it cannot be trusted.
4160fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
4161    let v: serde_json::Value = serde_json::from_str(json).ok()?;
4162    if v.get("errors").is_some_and(|e| !e.is_null()) {
4163        return None;
4164    }
4165    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
4166    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
4167    let contexts = commit.pointer("/statusCheckRollup/contexts");
4168    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
4169        // No rollup at all: the commit has no checks.
4170        return Some((oid.to_owned(), Vec::new()));
4171    };
4172    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
4173        return None;
4174    }
4175    let nodes = contexts.get("nodes")?.as_array()?;
4176    let rollup = nodes
4177        .iter()
4178        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
4179        .collect::<Result<Vec<_>, _>>()
4180        .ok()?;
4181    Some((oid.to_owned(), rollup))
4182}
4183
4184/// The pull request's last commit and its checks, in one response. `None`
4185/// when the forge could not be asked.
4186async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
4187    let out = gh(
4188        repo,
4189        &[
4190            "api".to_owned(),
4191            "graphql".to_owned(),
4192            "-F".to_owned(),
4193            "owner={owner}".to_owned(),
4194            "-F".to_owned(),
4195            "repo={repo}".to_owned(),
4196            "-F".to_owned(),
4197            format!("number={number}"),
4198            "-f".to_owned(),
4199            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
4200             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
4201             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
4202             ... on CheckRun{name status conclusion detailsUrl \
4203             isRequired(pullRequestNumber:$number)} \
4204             ... on StatusContext{context state targetUrl \
4205             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
4206                .to_owned(),
4207        ],
4208    )
4209    .await
4210    .ok()?;
4211    out.0.then_some(out.1)
4212}
4213
4214/// What a fix round did.
4215#[doc(hidden)]
4216#[derive(Debug, PartialEq)]
4217pub enum Fixed {
4218    /// The fixer committed something, and this is the head that was pushed.
4219    Committed {
4220        /// The commit now at the tip of the pushed branch.
4221        head: String,
4222    },
4223    /// The fixer ran and chose to change nothing.
4224    Declined,
4225    /// The fixer could not run, or said nothing usable.
4226    Failed(String),
4227}
4228
4229/// Hand the failures and the comments to the fixer, then commit and push.
4230///
4231/// The fixer works in the winner's own worktree so its commits land on the
4232/// branch the pull request is built from, and it runs with `allow_write` for
4233/// the same reason.
4234#[doc(hidden)]
4235pub async fn fix_round(
4236    state: &mut RunState,
4237    pr: &PrState,
4238    round: usize,
4239    budget: usize,
4240    reason: &str,
4241    logs: &str,
4242) -> Result<Fixed> {
4243    let winner = state
4244        .winner()
4245        .cloned()
4246        .context("landing needs a winning candidate; none is recorded on this run")?;
4247    let roles = state
4248        .config
4249        .resolve_roles()
4250        .context("resolve the roster for the fix round")?;
4251    // Same chain as the review loop (see `crate::fixer`): the configured
4252    // fixers in order, otherwise the winner's own author continuing its own
4253    // conversation - the competition is over, so its context is pure benefit.
4254    let attempts = crate::fixer::attempts(state, &roles, &winner);
4255    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4256
4257    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
4258    let mut artifacts = agent::artifacts_dir(&state.dir());
4259    if let Some(request) = state
4260        .merge_repairs
4261        .last()
4262        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
4263    {
4264        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
4265    }
4266    let prompt = if state.config.cache_dir().is_some() {
4267        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
4268    } else {
4269        prompt
4270    };
4271    // Read before the fixer runs: it normally commits for itself, so HEAD has
4272    // already moved by the time it returns and a later read would see no
4273    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
4274    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4275    // Each id at most once, forward only: the next one is asked only when the
4276    // call advances, and only the last attempt's result is judged below, so an
4277    // exhausted chain ends as a single failed fixer does.
4278    let mut last = None;
4279    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
4280        let mut seat = seat_of(state, &seat_key, &spec.id);
4281        let stem = if i == 0 {
4282            format!("land-{round}")
4283        } else {
4284            format!("land-{round}-{}", spec.id)
4285        };
4286        let out = agent::invoke(
4287            &spec,
4288            &mut seat,
4289            &Invocation {
4290                cwd: &winner.worktree,
4291                prompt: &prompt,
4292                timeout: Duration::from_secs(state.config.graph.timeout_fix),
4293                allow_write: true,
4294                unsandboxed: false,
4295                sessions: state.config.graph.sessions,
4296                artifacts: &artifacts,
4297                stem: &stem,
4298                run: &state.id,
4299                node: "land",
4300                cache_dir: state.config.cache_dir().as_deref(),
4301                attachments: &[],
4302                writable: &[],
4303            },
4304        )
4305        .await;
4306        state.seats.insert(seat.key.clone(), seat);
4307        if let Some(next) = ids.get(i + 1)
4308            && agent::chain_advances(&out)
4309        {
4310            let (class, why) = crate::fixer::failure_of(&out);
4311            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
4312            continue;
4313        }
4314        last = Some(out);
4315        break;
4316    }
4317    let out = last.expect("the fixer chain always has an entry");
4318
4319    match out {
4320        Ok(o) if o.quota_exhausted() => {
4321            return Ok(Fixed::Failed(
4322                "rate limited (quota); the fixer could not run".to_owned(),
4323            ));
4324        }
4325        Ok(o) if !o.usable() => {
4326            return Ok(Fixed::Failed(format!(
4327                "the fixer produced nothing usable (exit {:?}, timed out: {})",
4328                o.exit_code, o.timed_out
4329            )));
4330        }
4331        Ok(_) => {}
4332        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
4333    }
4334
4335    // An agent that edited files but never committed would otherwise push
4336    // nothing and look like a refusal.
4337    match git::rescue_commit(
4338        &winner.worktree,
4339        &format!("magi: land round {round} fixes (uncommitted work)"),
4340    )
4341    .await
4342    {
4343        Ok(r) => state.note_withheld("land", &r.withheld),
4344        Err(e)
4345            if state
4346                .merge_repairs
4347                .last()
4348                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
4349        {
4350            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
4351        }
4352        Err(_) => {}
4353    }
4354    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
4355    if after == before {
4356        return Ok(Fixed::Declined);
4357    }
4358
4359    let remote = state.config.merge.remote.clone();
4360    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
4361    let push = if state.pr_head.is_some() {
4362        let expected = state
4363            .pr_pushed_head
4364            .as_deref()
4365            .context("missing public head lease")?;
4366        git::push_as(
4367            &winner.worktree,
4368            &remote,
4369            &winner.branch,
4370            &public_head,
4371            expected,
4372        )
4373        .await?
4374    } else {
4375        git::push(&winner.worktree, &remote, &winner.branch).await?
4376    };
4377    if !push.ok() {
4378        return Ok(Fixed::Failed(format!(
4379            "pushing {} to {remote} failed: {}",
4380            public_head, push.stderr
4381        )));
4382    }
4383    state.event(
4384        "land",
4385        format!("round {round}: pushed a fix to {public_head}"),
4386    );
4387    state.pr_pushed_head = Some(after.clone());
4388    state.save()?;
4389    Ok(Fixed::Committed { head: after })
4390}
4391
4392/// Fetch or create a seat, keeping its conversation across nodes.
4393pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
4394    crate::fixer::seat_for(state, key, agent)
4395}
4396
4397/// Recheck the live PR and local worktree before spending the requested fix.
4398/// A moved/closed PR is held, never repaired using obsolete evidence.
4399pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
4400    let seen = observe(&state.repo, url).await?;
4401    let winner = state.winner().context("no winning branch to repair")?;
4402    if seen.pr.state != PrLifecycle::Open || seen.head != head {
4403        anyhow::bail!(
4404            "PR is closed or its head changed; review the current head before requesting repair again"
4405        );
4406    }
4407    if git::rev_parse(&winner.worktree, "HEAD").await? != head
4408        || !git::is_clean(&winner.worktree).await?
4409    {
4410        anyhow::bail!(
4411            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
4412        );
4413    }
4414    // Always disable any forge-side reservation, including one not recorded
4415    // by this run. A repair request is never merge authorization.
4416    let view = gh(
4417        &state.repo,
4418        &[
4419            "pr".into(),
4420            "view".into(),
4421            url.into(),
4422            "--json".into(),
4423            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
4424        ],
4425    )
4426    .await?;
4427    if !view.0 {
4428        bail!("cannot check auto-merge: {}", view.1);
4429    }
4430    let live: serde_json::Value = serde_json::from_str(&view.1)?;
4431    let public = state.pr_branch().unwrap_or(&winner.branch);
4432    if live["state"] != "OPEN"
4433        || live["headRefOid"] != head
4434        || live["headRefName"] != public
4435        || live["baseRefName"] != state.base_branch
4436        || live["isCrossRepository"] != false
4437    {
4438        bail!("PR changed during repair preflight; review its current head explicitly");
4439    }
4440    let auto_merge = live
4441        .get("autoMergeRequest")
4442        .context("cannot read the PR auto-merge reservation; repair held")?;
4443    if !auto_merge.is_null() {
4444        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
4445        if !out.0 {
4446            bail!("cannot disable auto-merge; repair held: {}", out.1);
4447        }
4448    }
4449    Ok(seen.pr)
4450}
4451
4452/// What the fixer is told.
4453fn fix_prompt(
4454    state: &RunState,
4455    pr: &PrState,
4456    round: usize,
4457    budget: usize,
4458    reason: &str,
4459    logs: &str,
4460) -> String {
4461    let mut s = format!(
4462        "Your patch is open as a pull request and it is not landing. Land round \
4463         {round} of {budget}.\n\n\
4464         Pull request: {}\n\n\
4465         What is holding it: {reason}\n\n\
4466         # The task\n\n{}\n",
4467        pr.url, state.instruction
4468    );
4469
4470    if pr.failing.is_empty() {
4471        s.push_str("\n# Failing checks\n\n(none)\n");
4472    } else {
4473        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
4474        if logs.trim().is_empty() {
4475            s.push_str("\nNo log could be read; reproduce the failure locally.\n");
4476        } else {
4477            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
4478        }
4479    }
4480
4481    if pr.review_comments.is_empty() {
4482        s.push_str("\n# Review comments\n\n(none)\n");
4483    } else {
4484        s.push_str("\n# Review comments\n");
4485        for c in &pr.review_comments {
4486            let where_ = match (&c.path, c.line) {
4487                (Some(p), Some(l)) => format!(" ({p}:{l})"),
4488                (Some(p), None) => format!(" ({p})"),
4489                _ => String::new(),
4490            };
4491            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
4492        }
4493    }
4494
4495    s.push_str(
4496        "\n# Rules\n\n\
4497         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
4498            failing test; do not silence a lint with an allow attribute; do not \
4499            stretch a timeout to hide a race. If the check is right, the code is \
4500            wrong.\n\
4501         2. Change nothing the checks and the comments did not raise. A \
4502            drive-by refactor turns a one-line fix into a pull request that \
4503            needs reviewing again.\n\
4504         3. If a comment is wrong, say so with a checkable argument and change \
4505            nothing for it. A declined comment with a reason is a correct \
4506            outcome; a change made to appease a reviewer is not.\n\
4507         4. Commit in this worktree. magi pushes to the pull request's branch \
4508            for you; do not push, merge, or close anything yourself.\n\
4509         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
4510         # Output\n\n\
4511         Say what you changed and why, and what you declined and why.",
4512    );
4513
4514    let language = &state.config.graph.language;
4515    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
4516        let _ = write!(s, "\n\nWrite all prose in {language}.");
4517    }
4518    // After the language line, so the exception is the last word on it.
4519    s.push_str(&crate::prompt::github_rule(
4520        language,
4521        &state.config.graph.github_language,
4522    ));
4523    if let Some(overlay) = state.config.prompts.overlay("fix") {
4524        let _ = write!(s, "\n\n{overlay}");
4525    }
4526    s
4527}
4528
4529/// Failing log tails, the way the operator collects them by hand:
4530/// `gh run view --log-failed`.
4531async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
4532    let mut out = String::new();
4533    for (name, url) in failing.iter().take(MAX_LOGS) {
4534        let args = match (job_of(url), run_of(url)) {
4535            (Some(job), _) => vec![
4536                "run".to_owned(),
4537                "view".to_owned(),
4538                "--log-failed".to_owned(),
4539                "--job".to_owned(),
4540                job,
4541            ],
4542            (None, Some(run)) => vec![
4543                "run".to_owned(),
4544                "view".to_owned(),
4545                run,
4546                "--log-failed".to_owned(),
4547            ],
4548            // Not a GitHub Actions check - an external status has no log here.
4549            (None, None) => continue,
4550        };
4551        let (ok, body) = match gh(repo, &args).await {
4552            Ok(v) => v,
4553            Err(e) => (false, format!("{e:#}")),
4554        };
4555        if !ok && body.trim().is_empty() {
4556            continue;
4557        }
4558        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
4559    }
4560    out
4561}
4562
4563/// Job id out of a check's `detailsUrl`
4564/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
4565fn job_of(details_url: &str) -> Option<String> {
4566    let after = details_url.split("/job/").nth(1)?;
4567    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4568    (!id.is_empty()).then_some(id)
4569}
4570
4571/// Workflow run id out of a check's `detailsUrl`.
4572pub(crate) fn run_of(details_url: &str) -> Option<String> {
4573    let after = details_url.split("/actions/runs/").nth(1)?;
4574    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
4575    (!id.is_empty()).then_some(id)
4576}
4577
4578/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
4579/// it lands on GitHub, not in front of the operator. Pure so a test can hold
4580/// it to that.
4581fn stop_comment(run_id: &str, why: &str) -> String {
4582    format!(
4583        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
4584         The branch is untouched and the run is `{run_id}`. Nothing was merged."
4585    )
4586}
4587
4588/// Leave the pull request open, say why on it, and mark the run blocked.
4589///
4590/// The comment is what makes an unattended stop actionable: the operator wakes
4591/// up to a pull request that explains itself rather than to a silent queue.
4592async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
4593    let body = stop_comment(&state.id, why);
4594    let (_, mut body) = crate::github_text::prepare(state, "", &body);
4595    if !body.contains(MARKER) {
4596        body = format!("{MARKER}\n{body}");
4597    }
4598    let posted = gh(
4599        repo,
4600        &[
4601            "pr".to_owned(),
4602            "comment".to_owned(),
4603            pr.number.to_string(),
4604            "--body".to_owned(),
4605            body,
4606        ],
4607    )
4608    .await;
4609    match posted {
4610        Ok((true, _)) => {}
4611        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
4612        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
4613    }
4614    state.status = RunStatus::Blocked;
4615    state.merge = Some(MergeOutcome {
4616        mode: MergeMode::Pr,
4617        ok: false,
4618        detail: why.to_owned(),
4619        empty: false,
4620    });
4621    state.event("land", format!("stopped: {why}"));
4622    state.save()?;
4623    Ok(())
4624}
4625
4626/// Run `gh` in `repo`, returning success and the combined output.
4627///
4628/// Combined because `gh` reports a refused merge on stderr and the pull request
4629/// json on stdout, and both are evidence.
4630///
4631/// `GH_REPO` is stripped from the child's environment: every call site here
4632/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
4633/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
4634/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
4635/// `GH_REPO` the operator happens to have exported for an unrelated script
4636/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
4637/// below) to a different repository than the one actually on disk - which
4638/// for the same-repo guard in [`correct_manual_merge`] would mean the check
4639/// could be made to agree with whatever repository a forged `--merged` URL
4640/// claims, defeating it entirely.
4641pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
4642    let out = tokio::process::Command::new("gh")
4643        .args(args)
4644        .current_dir(cwd)
4645        .env_remove("GH_REPO")
4646        .quiet()
4647        .stdin(std::process::Stdio::null())
4648        .output()
4649        .await
4650        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
4651    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
4652    let err = String::from_utf8_lossy(&out.stderr);
4653    if body.trim().is_empty() {
4654        body = err.into_owned();
4655    } else if !err.trim().is_empty() {
4656        body.push_str(&err);
4657    }
4658    Ok((out.status.success(), body.trim().to_owned()))
4659}
4660
4661/// Verdict of one entry in the status rollup.
4662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4663pub(crate) enum Verdict {
4664    Pass,
4665    Fail,
4666    Pending,
4667    Unknown,
4668}
4669
4670#[derive(Debug, Deserialize)]
4671#[serde(rename_all = "camelCase")]
4672struct GhPr {
4673    #[serde(default)]
4674    url: String,
4675    #[serde(default)]
4676    number: u64,
4677    #[serde(default)]
4678    state: String,
4679    #[serde(default)]
4680    title: String,
4681    #[serde(default)]
4682    status_check_rollup: Vec<GhCheck>,
4683    /// GitHub's own verdict on whether the pull request can be merged.
4684    ///
4685    /// Worth asking for because it is the only place the *required* check set
4686    /// is applied: the rollup lists every check equally, so a repository that
4687    /// deliberately does not require `coverage` still looks red here. See
4688    /// [`Blocking`].
4689    #[serde(default)]
4690    merge_state_status: String,
4691    /// The commit the pull request currently points at. Compared with the
4692    /// commit a fix round pushed, it is how the loop knows the forge has moved
4693    /// on and the rollup belongs to the new head.
4694    #[serde(default)]
4695    head_ref_oid: String,
4696    #[serde(default)]
4697    base_ref_name: String,
4698    #[serde(default)]
4699    reviews: Vec<GhReview>,
4700    #[serde(default)]
4701    comments: Vec<GhComment>,
4702}
4703
4704/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
4705/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
4706/// status API, which is how CodeRabbit reports - has `context`/`state` and no
4707/// conclusion at all.
4708#[derive(Debug, Deserialize)]
4709#[serde(rename_all = "camelCase")]
4710struct GhCheck {
4711    #[serde(default)]
4712    name: Option<String>,
4713    #[serde(default)]
4714    context: Option<String>,
4715    #[serde(default)]
4716    status: Option<String>,
4717    #[serde(default)]
4718    conclusion: Option<String>,
4719    #[serde(default)]
4720    state: Option<String>,
4721    #[serde(default)]
4722    details_url: Option<String>,
4723    #[serde(default)]
4724    target_url: Option<String>,
4725    /// Whether the base branch requires this check. Only the GraphQL node
4726    /// carries it; `None` is "not read", never "not required".
4727    #[serde(default)]
4728    is_required: Option<bool>,
4729}
4730
4731impl GhCheck {
4732    /// Name to show a human and hand to the fixer.
4733    fn label(&self) -> String {
4734        self.name
4735            .clone()
4736            .or_else(|| self.context.clone())
4737            .unwrap_or_else(|| "(unnamed check)".to_owned())
4738    }
4739
4740    /// Where this check's logs live, when it has any.
4741    fn url(&self) -> Option<&str> {
4742        self.details_url
4743            .as_deref()
4744            .or(self.target_url.as_deref())
4745            .filter(|u| !u.is_empty())
4746    }
4747
4748    /// Did it pass?
4749    ///
4750    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
4751    /// skips release and bot pull requests by design, and a skip that blocked
4752    /// landing would block exactly the pull requests that need no review.
4753    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
4754    /// merging over one is merging over a check that never ran.
4755    fn verdict(&self) -> Verdict {
4756        if let Some(status) = self.status.as_deref() {
4757            if !status.eq_ignore_ascii_case("COMPLETED") {
4758                return Verdict::Pending;
4759            }
4760        }
4761        let outcome = self
4762            .conclusion
4763            .as_deref()
4764            .or(self.state.as_deref())
4765            .unwrap_or("");
4766        match outcome.to_ascii_uppercase().as_str() {
4767            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
4768            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
4769            | "ACTION_REQUIRED" => Verdict::Fail,
4770            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
4771                Verdict::Pending
4772            }
4773            _ => Verdict::Unknown,
4774        }
4775    }
4776}
4777
4778#[derive(Debug, Deserialize)]
4779struct GhAuthor {
4780    #[serde(default)]
4781    login: String,
4782}
4783
4784#[derive(Debug, Deserialize)]
4785struct GhReview {
4786    #[serde(default)]
4787    author: GhAuthor,
4788    #[serde(default)]
4789    body: String,
4790}
4791
4792#[derive(Debug, Deserialize)]
4793struct GhComment {
4794    #[serde(default)]
4795    author: GhAuthor,
4796    #[serde(default)]
4797    body: String,
4798}
4799
4800#[derive(Debug, Deserialize)]
4801struct GhUser {
4802    #[serde(default)]
4803    login: String,
4804}
4805
4806#[derive(Debug, Deserialize)]
4807struct GhInline {
4808    #[serde(default)]
4809    user: GhUser,
4810    #[serde(default)]
4811    path: Option<String>,
4812    #[serde(default)]
4813    line: Option<u64>,
4814    #[serde(default)]
4815    body: String,
4816}
4817
4818impl Default for GhAuthor {
4819    fn default() -> Self {
4820        Self {
4821            login: "(unknown)".to_owned(),
4822        }
4823    }
4824}
4825
4826impl Default for GhUser {
4827    fn default() -> Self {
4828        Self {
4829            login: "(unknown)".to_owned(),
4830        }
4831    }
4832}
4833
4834#[cfg(test)]
4835mod tests {
4836    use super::*;
4837    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
4838
4839    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
4840        format!(
4841            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
4842        )
4843    }
4844
4845    #[test]
4846    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
4847        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
4848        assert_eq!(
4849            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
4850            Ok(())
4851        );
4852        for (json, why) in [
4853            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
4854            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
4855            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
4856            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
4857            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
4858            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
4859        ] {
4860            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
4861                .unwrap_err()
4862                .why;
4863            assert!(err.contains(why), "{json}: {err}");
4864        }
4865        // A head that moved on past what was verified is left alone.
4866        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
4867        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
4868        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
4869        assert!(
4870            !closable(
4871                &head_json("x", "main", "OPEN", false),
4872                "magi/27b2/A",
4873                "main",
4874                &[]
4875            )
4876            .unwrap_err()
4877            .retry
4878        );
4879        assert!(is_forge_url("https://github.com/o/r.git"));
4880        assert!(is_forge_url("git@github.com:o/r.git"));
4881        assert!(!is_forge_url("/tmp/origin.git"));
4882        assert!(!is_forge_url("C:\\work\\origin.git"));
4883        assert!(!is_forge_url("file:///tmp/origin.git"));
4884        assert!(forge_unavailable(
4885            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
4886        ));
4887        assert!(!forge_unavailable(
4888            "gh pr list failed: error connecting to api.github.com"
4889        ));
4890        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
4891        // A record that does not say whether it is a fork is not trusted.
4892        assert!(
4893            closable(
4894                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
4895                "b",
4896                "main",
4897                &["aaa".to_owned()]
4898            )
4899            .is_err()
4900        );
4901    }
4902
4903    #[test]
4904    fn the_close_comment_names_the_commit_on_the_base() {
4905        let e = crate::already::Evidence {
4906            proof: crate::already::Proof::PatchId,
4907            tip: "1234567890".to_owned(),
4908            commits: vec!["0e368de0000".to_owned()],
4909        };
4910        let c = superseded_comment("main", &e);
4911        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
4912    }
4913
4914    /// 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.
4915    const GREEN_OPEN: &str = r####"{
4916  "url": "https://github.com/yukimemi/magi/pull/10",
4917  "number": 10,
4918  "state": "OPEN",
4919  "mergeStateStatus": "CLEAN",
4920  "statusCheckRollup": [
4921    {
4922      "__typename": "CheckRun",
4923      "conclusion": "SKIPPED",
4924      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
4925      "name": "review",
4926      "status": "COMPLETED",
4927      "workflowName": "claude-review"
4928    },
4929    {
4930      "__typename": "CheckRun",
4931      "conclusion": "SUCCESS",
4932      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
4933      "name": "check (ubuntu-latest)",
4934      "status": "COMPLETED",
4935      "workflowName": "CI"
4936    },
4937    {
4938      "__typename": "CheckRun",
4939      "conclusion": "SUCCESS",
4940      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
4941      "name": "rustfmt",
4942      "status": "COMPLETED",
4943      "workflowName": "CI"
4944    },
4945    {
4946      "__typename": "StatusContext",
4947      "context": "CodeRabbit",
4948      "state": "SUCCESS",
4949      "targetUrl": ""
4950    }
4951  ],
4952  "reviews": [],
4953  "comments": [
4954    {
4955      "author": {
4956        "login": "coderabbitai"
4957      },
4958      "authorAssociation": "NONE",
4959      "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"
4960    }
4961  ]
4962}"####;
4963
4964    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
4965    const RED_OPEN: &str = r####"{
4966  "url": "https://github.com/yukimemi/magi/pull/9",
4967  "number": 9,
4968  "state": "OPEN",
4969  "mergeStateStatus": "UNSTABLE",
4970  "statusCheckRollup": [
4971    {
4972      "__typename": "CheckRun",
4973      "conclusion": "SUCCESS",
4974      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
4975      "name": "check (ubuntu-latest)",
4976      "status": "COMPLETED",
4977      "workflowName": "CI"
4978    },
4979    {
4980      "__typename": "CheckRun",
4981      "conclusion": "SUCCESS",
4982      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
4983      "name": "rustfmt",
4984      "status": "COMPLETED",
4985      "workflowName": "CI"
4986    },
4987    {
4988      "__typename": "CheckRun",
4989      "conclusion": "FAILURE",
4990      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
4991      "name": "editorconfig",
4992      "status": "COMPLETED",
4993      "workflowName": "CI"
4994    },
4995    {
4996      "__typename": "StatusContext",
4997      "context": "CodeRabbit",
4998      "state": "SUCCESS",
4999      "targetUrl": ""
5000    }
5001  ],
5002  "reviews": [],
5003  "comments": [
5004    {
5005      "author": {
5006        "login": "coderabbitai"
5007      },
5008      "authorAssociation": "NONE",
5009      "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"
5010    }
5011  ]
5012}"####;
5013
5014    /// 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.
5015    const PENDING_OPEN: &str = r####"{
5016  "url": "https://github.com/yukimemi/magi/pull/9",
5017  "number": 9,
5018  "state": "OPEN",
5019  "statusCheckRollup": [
5020    {
5021      "__typename": "CheckRun",
5022      "conclusion": "SUCCESS",
5023      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5024      "name": "check (ubuntu-latest)",
5025      "status": "COMPLETED",
5026      "workflowName": "CI"
5027    },
5028    {
5029      "__typename": "CheckRun",
5030      "conclusion": "SUCCESS",
5031      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5032      "name": "rustfmt",
5033      "status": "COMPLETED",
5034      "workflowName": "CI"
5035    },
5036    {
5037      "__typename": "CheckRun",
5038      "conclusion": null,
5039      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5040      "name": "editorconfig",
5041      "status": "IN_PROGRESS",
5042      "workflowName": "CI"
5043    },
5044    {
5045      "__typename": "StatusContext",
5046      "context": "CodeRabbit",
5047      "state": "SUCCESS",
5048      "targetUrl": ""
5049    }
5050  ],
5051  "reviews": [],
5052  "comments": []
5053}"####;
5054
5055    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
5056    const MERGED: &str = r####"{
5057  "url": "https://github.com/yukimemi/magi/pull/16",
5058  "number": 16,
5059  "state": "MERGED",
5060  "statusCheckRollup": [
5061    {
5062      "__typename": "CheckRun",
5063      "conclusion": "SUCCESS",
5064      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
5065      "name": "check (ubuntu-latest)",
5066      "status": "COMPLETED",
5067      "workflowName": "CI"
5068    },
5069    {
5070      "__typename": "CheckRun",
5071      "conclusion": "SUCCESS",
5072      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
5073      "name": "review",
5074      "status": "COMPLETED",
5075      "workflowName": "claude-review"
5076    }
5077  ],
5078  "reviews": [],
5079  "comments": []
5080}"####;
5081
5082    /// 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.
5083    const REVIEWED_OPEN: &str = r####"{
5084  "url": "https://github.com/yukimemi/magi/pull/12",
5085  "number": 12,
5086  "state": "OPEN",
5087  "statusCheckRollup": [
5088    {
5089      "__typename": "CheckRun",
5090      "conclusion": "SUCCESS",
5091      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
5092      "name": "check (ubuntu-latest)",
5093      "status": "COMPLETED",
5094      "workflowName": "CI"
5095    },
5096    {
5097      "__typename": "CheckRun",
5098      "conclusion": "SUCCESS",
5099      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
5100      "name": "review",
5101      "status": "COMPLETED",
5102      "workflowName": "claude-review"
5103    }
5104  ],
5105  "reviews": [
5106    {
5107      "author": {
5108        "login": "claude"
5109      },
5110      "state": "COMMENTED",
5111      "body": ""
5112    }
5113  ],
5114  "comments": [
5115    {
5116      "author": {
5117        "login": "coderabbitai"
5118      },
5119      "authorAssociation": "NONE",
5120      "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"
5121    },
5122    {
5123      "author": {
5124        "login": "claude"
5125      },
5126      "authorAssociation": "NONE",
5127      "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"
5128    }
5129  ]
5130}"####;
5131
5132    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
5133    const INLINE: &str = r####"[
5134  {
5135    "user": {
5136      "login": "claude[bot]"
5137    },
5138    "path": "src/graph.rs",
5139    "line": 231,
5140    "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"
5141  }
5142]"####;
5143
5144    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
5145    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
5146<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
5147
5148> [!IMPORTANT]
5149> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
5150> 
5151> This repository does not receive automatic reviews because it has fewer than 10 stars.
5152> 
5153> <details>
5154> <summary>⚙️ Run configuration</summary>
5155> 
5156> **Configuration used**: defaults
5157> 
5158> **Review profile**: CHILL
5159> 
5160> **Plan**: Team
5161> 
5162> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
5163> 
5164> </details>
5165
5166<!-- end of auto-generated comment: skip review by coderabbit.ai -->
5167
5168<!-- tips_start -->
5169
5170---
5171
5172Thanks 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.
5173
5174<details>
5175<summary>❤️ Share</summary>
5176
5177- [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"####;
5178
5179    /// 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.
5180    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
5181
5182---
5183### Reviewing PR #16
5184
5185- [x] Read AGENTS.md conventions
5186- [x] Review `src/daemon.rs` changes
5187- [x] Review `src/main.rs` changes (new `doctor` reporting)
5188- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
5189- [x] Check test coverage for new behavior
5190- [x] Run verification commands (blocked — see note)
5191- [x] Post findings"####;
5192
5193    /// The same job's real comment on pull request #12 once it had something to say.
5194    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
5195
5196---
5197### Review: `magi review <branch>` — cheap-half-only graph
5198
5199Read 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.
5200
5201**Correctness**
5202
5203- 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"####;
5204
5205    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
5206        PrState {
5207            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
5208            number: 16,
5209            state: PrLifecycle::Open,
5210            checks,
5211            // These tests are about red-means-fix, so a red here is one the
5212            // forge gates on. Without saying so they would assert the new
5213            // "merge past a check nobody requires" path by accident.
5214            blocking: if matches!(checks, Checks::Red) {
5215                Blocking::Yes
5216            } else {
5217                Blocking::No
5218            },
5219            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
5220            review_comments: (0..comments)
5221                .map(|i| ReviewComment {
5222                    author: "coderabbitai".to_owned(),
5223                    path: Some("src/graph.rs".to_owned()),
5224                    line: Some(231),
5225                    body: format!("finding {i}"),
5226                })
5227                .collect(),
5228        }
5229    }
5230
5231    #[test]
5232    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
5233        use CiExpectation::{Absent, Expected};
5234        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
5235            let p = pr(checks, &["x"], 0);
5236            for waited in [Duration::ZERO, CHECKS_GRACE] {
5237                assert_eq!(
5238                    decide_with(&p, 0, 4, waited, Expected),
5239                    decide(&p, 0, 4, waited)
5240                );
5241            }
5242        }
5243        // Nothing will ever report: no wait, no give-up, no fix round.
5244        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
5245            let p = pr(checks, &["x"], 0);
5246            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
5247            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
5248        }
5249        // A conflict is not a check: it still wants the rebase.
5250        let mut p = pr(Checks::Unknown, &[], 0);
5251        p.blocking = Blocking::Conflict;
5252        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
5253        // And a pull request that left our hands is still done.
5254        p.state = PrLifecycle::Merged;
5255        assert_eq!(
5256            decide_with(&p, 0, 4, Duration::ZERO, Absent),
5257            Step::Done { merged: true }
5258        );
5259    }
5260
5261    #[test]
5262    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
5263        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
5264        assert_eq!(state.number, 10);
5265        assert_eq!(state.state, PrLifecycle::Open);
5266        assert_eq!(state.checks, Checks::Green);
5267        assert!(state.failing.is_empty());
5268        assert!(
5269            state.review_comments.is_empty(),
5270            "the only comment is CodeRabbit's trigger notice: {:?}",
5271            state.review_comments
5272        );
5273        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
5274    }
5275
5276    #[test]
5277    fn a_failing_check_parses_as_red_and_is_named() {
5278        let state = parse_pr(RED_OPEN).expect("red fixture parses");
5279        assert_eq!(state.checks, Checks::Red);
5280        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
5281        // The captured payload says `UNSTABLE` - mergeable, with a check
5282        // nobody requires red - which is exactly the shape that had to be
5283        // merged by hand. Asserted separately, in
5284        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
5285        // test is about is that a red check is *named*, so the reason a fixer
5286        // is handed says which one; so it asks the blocking question here.
5287        let mut blocking = state.clone();
5288        blocking.blocking = Blocking::Yes;
5289        match decide(&blocking, 0, 4, Duration::ZERO) {
5290            Step::Fix { reason } => {
5291                assert!(reason.contains("editorconfig"), "reason: {reason}");
5292                assert!(reason.contains("failing"), "reason: {reason}");
5293            }
5294            other => panic!("expected a fix round, got {other:?}"),
5295        }
5296    }
5297
5298    #[test]
5299    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
5300        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
5301        assert_eq!(state.checks, Checks::Pending);
5302        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
5303    }
5304
5305    #[test]
5306    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
5307        let state = parse_pr(MERGED).expect("merged fixture parses");
5308        assert_eq!(state.state, PrLifecycle::Merged);
5309        assert_eq!(
5310            decide(&state, 0, 4, Duration::ZERO),
5311            Step::Done { merged: true }
5312        );
5313    }
5314
5315    #[test]
5316    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
5317        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
5318        assert_eq!(state.checks, Checks::Green);
5319        let authors: Vec<&str> = state
5320            .review_comments
5321            .iter()
5322            .map(|c| c.author.as_str())
5323            .collect();
5324        assert_eq!(
5325            authors,
5326            vec!["claude"],
5327            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
5328        );
5329        match decide(&state, 0, 4, Duration::ZERO) {
5330            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
5331            other => panic!("expected a fix round, got {other:?}"),
5332        }
5333    }
5334
5335    #[test]
5336    fn inline_review_comments_keep_their_file_and_line() {
5337        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
5338        assert_eq!(comments.len(), 1);
5339        assert_eq!(comments[0].author, "claude[bot]");
5340        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
5341        assert_eq!(comments[0].line, Some(231));
5342        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
5343    }
5344
5345    #[test]
5346    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
5347        assert!(
5348            is_noise(CODERABBIT_TRIGGER),
5349            "CodeRabbit's trigger notice declares itself not a review"
5350        );
5351        assert!(
5352            is_noise(CLAUDE_CHECKLIST),
5353            "a progress checklist asks for nothing"
5354        );
5355        assert!(
5356            !is_noise(CLAUDE_FINDING),
5357            "a review that names a bug is input, not noise"
5358        );
5359
5360        let mut clean = pr(Checks::Green, &[], 0);
5361        clean.review_comments.push(ReviewComment {
5362            author: "coderabbitai".to_owned(),
5363            path: None,
5364            line: None,
5365            body: CODERABBIT_TRIGGER.to_owned(),
5366        });
5367        clean.review_comments.retain(|c| !is_noise(&c.body));
5368        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
5369
5370        let mut found = pr(Checks::Green, &[], 0);
5371        found.review_comments.push(ReviewComment {
5372            author: "claude".to_owned(),
5373            path: None,
5374            line: None,
5375            body: CLAUDE_FINDING.to_owned(),
5376        });
5377        found.review_comments.retain(|c| !is_noise(&c.body));
5378        assert!(matches!(
5379            decide(&found, 0, 4, Duration::ZERO),
5380            Step::Fix { .. }
5381        ));
5382    }
5383
5384    #[test]
5385    fn the_policy_table_holds_for_every_combination_that_matters() {
5386        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
5387            (
5388                "pending checks are waited for, even on the last round",
5389                pr(Checks::Pending, &[], 0),
5390                4,
5391                4,
5392                Duration::ZERO,
5393                Step::Wait,
5394            ),
5395            (
5396                "red checks are fixed",
5397                pr(Checks::Red, &["editorconfig"], 0),
5398                0,
5399                4,
5400                Duration::ZERO,
5401                Step::Fix {
5402                    reason: "1 check(s) failing: editorconfig".to_owned(),
5403                },
5404            ),
5405            (
5406                "green with comments is fixed, not merged",
5407                pr(Checks::Green, &[], 2),
5408                1,
5409                4,
5410                Duration::ZERO,
5411                Step::Fix {
5412                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
5413                        .to_owned(),
5414                },
5415            ),
5416            (
5417                "green and clean merges",
5418                pr(Checks::Green, &[], 0),
5419                3,
5420                4,
5421                Duration::ZERO,
5422                Step::Merge,
5423            ),
5424            (
5425                "an unreadable rollup is waited on while the grace lasts",
5426                pr(Checks::Unknown, &[], 0),
5427                0,
5428                4,
5429                Duration::ZERO,
5430                Step::Wait,
5431            ),
5432            (
5433                "an unreadable rollup is never merged once the grace is spent",
5434                pr(Checks::Unknown, &[], 0),
5435                0,
5436                4,
5437                CHECKS_GRACE,
5438                Step::GiveUp {
5439                    reason: "no check status is readable on the pull request after 3 minute(s); \
5440                             refusing to merge on a guess"
5441                        .to_owned(),
5442                },
5443            ),
5444        ];
5445        for (what, state, round, budget, waited, want) in cases {
5446            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
5447        }
5448    }
5449
5450    #[test]
5451    fn the_forge_verdict_survives_the_round_trip_from_gh() {
5452        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
5453        // requested but never parsed is the kind of thing that looks wired up
5454        // and answers `Unsaid` forever.
5455        let green = parse_pr(GREEN_OPEN).expect("parse");
5456        assert_eq!(green.blocking, Blocking::No);
5457        let red = parse_pr(RED_OPEN).expect("parse");
5458        assert_eq!(
5459            red.blocking,
5460            Blocking::No,
5461            "`UNSTABLE` is mergeable: the red check is one nobody requires"
5462        );
5463        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
5464        // A payload from an older `gh` has no such field at all.
5465        let quiet =
5466            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
5467        assert_eq!(quiet.blocking, Blocking::Unsaid);
5468    }
5469
5470    #[test]
5471    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
5472        // Pull request 37's only red check was `editorconfig`, failing
5473        // because the action could not fetch its own binary after
5474        // editorconfig-checker v4 renamed its release assets. The repository
5475        // does not require it. magi answered by asking a fixer to repair a
5476        // change that was fine, and the pull request had to be merged by hand.
5477        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
5478        nonblocking.blocking = Blocking::No;
5479        assert_eq!(
5480            decide(&nonblocking, 0, 4, Duration::ZERO),
5481            Step::Merge,
5482            "the forge says nothing is in the way, so nothing is"
5483        );
5484
5485        // The same red, gated on: that is a fix round, as before.
5486        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
5487        blocking.blocking = Blocking::Yes;
5488        assert!(matches!(
5489            decide(&blocking, 0, 4, Duration::ZERO),
5490            Step::Fix { .. }
5491        ));
5492
5493        // A review comment still outranks green-enough: a non-required red
5494        // must not become a way to merge past an unanswered reviewer.
5495        let mut commented = pr(Checks::Red, &["coverage"], 1);
5496        commented.blocking = Blocking::No;
5497        assert!(matches!(
5498            decide(&commented, 0, 4, Duration::ZERO),
5499            Step::Fix { .. }
5500        ));
5501
5502        // And silence from the forge is not consent.
5503        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
5504        unsaid.blocking = Blocking::Unsaid;
5505        assert!(matches!(
5506            decide(&unsaid, 0, 4, Duration::ZERO),
5507            Step::Fix { .. }
5508        ));
5509    }
5510
5511    #[test]
5512    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
5513        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
5514        red.blocking = Blocking::No;
5515        assert_eq!(
5516            decide(&red, 0, 4, Duration::ZERO),
5517            Step::Merge,
5518            "announcing must not change the decision"
5519        );
5520        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
5521        assert!(said.contains("yukimemi/magi"), "{said}");
5522        assert!(said.contains("#16"), "{said}");
5523        assert!(
5524            said.contains("https://github.com/yukimemi/magi/pull/16"),
5525            "{said}"
5526        );
5527        assert!(
5528            said.contains("test (windows-latest)") && said.contains("coverage"),
5529            "{said}"
5530        );
5531
5532        // `failing` can be left over on a green observation; only `checks` counts.
5533        let green = pr(Checks::Green, &["stale"], 0);
5534        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
5535    }
5536
5537    #[test]
5538    fn the_repo_label_comes_from_the_pull_request_url() {
5539        let p = Path::new("/tmp/checkout");
5540        assert_eq!(
5541            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
5542            "yukimemi/magi"
5543        );
5544        assert_eq!(repo_label(p, "not a url"), "checkout");
5545    }
5546
5547    #[test]
5548    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
5549        // Pull requests 35 and 37 were both rebased by hand: a competition
5550        // that runs for two hours against a repository merging pull requests
5551        // all day conflicts on the way in, and that is arithmetic rather
5552        // than a defect in the change.
5553        let mut conflicted = pr(Checks::Green, &[], 0);
5554        conflicted.blocking = Blocking::Conflict;
5555        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
5556
5557        // Decided before the checks, and even with the rounds spent: every
5558        // check on a branch that cannot land is an answer about a state that
5559        // cannot land, and a conflict is not the change's fault.
5560        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
5561        red.blocking = Blocking::Conflict;
5562        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
5563
5564        // The lifecycle still wins over everything, conflict included.
5565        let mut merged = pr(Checks::Red, &[], 0);
5566        merged.blocking = Blocking::Conflict;
5567        merged.state = PrLifecycle::Merged;
5568        assert_eq!(
5569            decide(&merged, 0, 4, Duration::ZERO),
5570            Step::Done { merged: true }
5571        );
5572    }
5573
5574    #[test]
5575    fn the_forge_verdict_is_read_off_merge_state_status() {
5576        // The spellings that mean "mergeable". `UNSTABLE` is the one that
5577        // matters: mergeable, with a non-required check red or still running.
5578        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
5579            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
5580            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
5581        }
5582        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
5583        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
5584        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
5585        // An older `gh`, or a token without the scope, says nothing - and
5586        // refusing to guess is the rule everywhere else in this module.
5587        for quiet in ["", "UNKNOWN"] {
5588            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
5589            assert!(Blocking::of(quiet).stops_a_merge());
5590        }
5591    }
5592
5593    #[test]
5594    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
5595        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
5596        // The exact stderr from run ec12, in a jj-colocated repository.
5597        let jj = "could not determine current branch: failed to run git: not on any branch";
5598
5599        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
5600            .expect("the forge says merged, so it merged");
5601        assert!(landed.ok);
5602        assert!(
5603            landed.detail.contains("but the pull request is merged"),
5604            "the record must not read as a clean success: {}",
5605            landed.detail
5606        );
5607        assert!(
5608            landed.detail.contains("not on any branch"),
5609            "and it must keep what the command actually said: {}",
5610            landed.detail
5611        );
5612
5613        // A pull request still open means the merge really failed.
5614        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
5615        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
5616        // And an unreadable answer is not evidence of success.
5617        assert!(merged_after_all(&argv, jj, None).is_none());
5618    }
5619
5620    #[test]
5621    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
5622        let mut state = pr(Checks::Red, &["editorconfig"], 3);
5623        state.state = PrLifecycle::Closed;
5624        assert_eq!(
5625            decide(&state, 0, 4, Duration::ZERO),
5626            Step::Done { merged: false },
5627            "a human closing the pull request ends the loop, whatever CI says"
5628        );
5629    }
5630
5631    #[test]
5632    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
5633        let red = decide(
5634            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
5635            4,
5636            4,
5637            Duration::ZERO,
5638        );
5639        match red {
5640            Step::GiveUp { reason } => {
5641                assert!(reason.contains("editorconfig"), "reason: {reason}");
5642                assert!(reason.contains("test (macos)"), "reason: {reason}");
5643                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
5644            }
5645            other => panic!("expected a give-up, got {other:?}"),
5646        }
5647
5648        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
5649        match commented {
5650            Step::GiveUp { reason } => {
5651                assert!(reason.contains("unresolved"), "reason: {reason}");
5652                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
5653            }
5654            other => panic!("expected a give-up, got {other:?}"),
5655        }
5656    }
5657
5658    #[test]
5659    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
5660        let candidate_commit = "magi: candidate A (uncommitted work)";
5661        let subject = merge_subject(candidate_commit, "add retries to the uploader");
5662        let argv = merge_argv(16, &subject);
5663
5664        assert!(argv.contains(&"--squash".to_owned()));
5665        assert!(argv.contains(&"--delete-branch".to_owned()));
5666        assert!(argv.contains(&"--subject".to_owned()));
5667        assert_eq!(
5668            argv.last().map(String::as_str),
5669            Some("add retries to the uploader"),
5670            "the subject must not be the candidate commit message"
5671        );
5672        assert_ne!(subject, candidate_commit);
5673    }
5674
5675    #[test]
5676    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
5677        assert_eq!(
5678            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
5679            "feat: a queue, an unattended loop, and a phone UI"
5680        );
5681        assert_eq!(
5682            merge_subject("", "# port the retry logic\n\ndetails"),
5683            "port the retry logic",
5684            "an empty title falls back to the task's first line, heading marks stripped"
5685        );
5686    }
5687
5688    #[test]
5689    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
5690        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
5691        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
5692        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
5693        assert_eq!(job_of("https://coderabbit.ai/status"), None);
5694        assert_eq!(run_of(""), None);
5695    }
5696
5697    #[test]
5698    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
5699        let mut out = Vec::new();
5700        push_if_outstanding(
5701            &mut out,
5702            ReviewComment {
5703                author: "yukimemi".to_owned(),
5704                path: None,
5705                line: None,
5706                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
5707            },
5708        );
5709        assert!(out.is_empty());
5710    }
5711
5712    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
5713    /// falls back to the repository - which keeps these tests free of a
5714    /// worktree, a `git` invocation and a network.
5715    fn run_state() -> RunState {
5716        let mut state = RunState::new(
5717            std::path::PathBuf::from("/repo/magi"),
5718            "main".to_owned(),
5719            "abcdef1234".to_owned(),
5720            "add retries to the uploader".to_owned(),
5721            crate::config::Config::default(),
5722        );
5723        // Tests run in parallel against one persistent home and the ids
5724        // `RunState::new` draws from the clock can repeat, so two tests would
5725        // share a run's questions. The home also outlives the process, so the
5726        // counter alone would reuse an earlier run's ids.
5727        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
5728        let nanos = std::time::SystemTime::now()
5729            .duration_since(std::time::UNIX_EPOCH)
5730            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
5731        state.id = format!(
5732            "20261004-{nanos:06}-{:04x}",
5733            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
5734        );
5735        state
5736    }
5737
5738    fn green_pr() -> PrState {
5739        PrState {
5740            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
5741            number: 42,
5742            state: PrLifecycle::Open,
5743            checks: Checks::Green,
5744            // The forge sees nothing in the way unless a test says otherwise.
5745            blocking: Blocking::No,
5746            failing: Vec::new(),
5747            review_comments: vec![ReviewComment {
5748                author: "coderabbitai".to_owned(),
5749                path: Some("src/land.rs".to_owned()),
5750                line: Some(212),
5751                body: "this branch never checks the exit code".to_owned(),
5752            }],
5753        }
5754    }
5755
5756    #[test]
5757    fn github_facing_land_text_is_english_whatever_the_language() {
5758        let mut state = run_state();
5759        state.config.graph.language = "ja".to_owned();
5760        let comment = stop_comment(&state.id, "checks are still red");
5761        assert!(crate::github_text::check("", &comment).is_empty());
5762        assert!(comment.is_ascii(), "{comment}");
5763        assert!(comment.starts_with(MARKER));
5764
5765        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5766        let ja_at = p.find("Write all prose in ja").unwrap();
5767        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
5768        assert!(ja_at < rule_at, "{p}");
5769        assert!(p.contains("stays in Japanese"), "{p}");
5770
5771        state.config.graph.language = "en".to_owned();
5772        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
5773        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
5774        assert!(!p.contains("does not apply"), "{p}");
5775    }
5776
5777    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
5778
5779    fn panel() -> String {
5780        approval_panel(
5781            &run_state(),
5782            &green_pr(),
5783            NUMSTAT,
5784            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
5785            &[
5786                "land: ask before merging".to_owned(),
5787                "land: colour the diff".to_owned(),
5788            ],
5789            "feat: merge approval from the phone",
5790        )
5791    }
5792
5793    #[test]
5794    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
5795        let html = panel();
5796        for needle in [
5797            "42",
5798            "main",
5799            "src/land.rs",
5800            "src/web.rs",
5801            "assets/logo.png",
5802            "feat: merge approval from the phone",
5803            "land: ask before merging",
5804            "land: colour the diff",
5805            "coderabbitai",
5806            "this branch never checks the exit code",
5807            "green",
5808        ] {
5809            assert!(html.contains(needle), "the panel must state `{needle}`");
5810        }
5811    }
5812
5813    /// A candidate whose label is `A` and has won, so [`RunState::winner`]
5814    /// resolves to it.
5815    fn winning_candidate(summary: &str) -> Candidate {
5816        Candidate {
5817            index: 0,
5818            label: 'A',
5819            agent: "opus".to_owned(),
5820            branch: "magi/x/A".to_owned(),
5821            worktree: PathBuf::from("/wt/A"),
5822            summary: summary.to_owned(),
5823            stat: String::new(),
5824            files: 1,
5825            commits: 1,
5826            empty: false,
5827            failed: None,
5828            verified_noop: None,
5829            duration_ms: 0,
5830            folded: false,
5831        }
5832    }
5833
5834    fn uncontested_tally() -> Tally {
5835        Tally {
5836            first_choice: BTreeMap::from([('A', 1)]),
5837            borda: BTreeMap::new(),
5838            winner: 'A',
5839            rankings: 1,
5840            unanimous_initial: true,
5841            deliberated: false,
5842            changed_votes: 0,
5843            unanimous_final: true,
5844            tie_break: None,
5845            judges: 1,
5846            present: 1,
5847            quorum: 1,
5848            met_quorum: true,
5849            uncontested: None,
5850        }
5851    }
5852
5853    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
5854        ReviewRecord {
5855            attempts: 0,
5856            reviewer,
5857            agent: agent.to_owned(),
5858            summary: summary.to_owned(),
5859            findings: Vec::new(),
5860            vote: None,
5861            failed: None,
5862            duration_ms: 0,
5863        }
5864    }
5865
5866    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
5867        let answered = reviews.len();
5868        ReviewRound {
5869            round,
5870            head: "abc1234".to_owned(),
5871            verified_head: None,
5872            verified_at: None,
5873            reviews,
5874            e2e: Vec::new(),
5875            verify_retried: false,
5876            e2e_deferred: false,
5877            e2e_defer_reason: None,
5878            fix: None,
5879            blocking: 0,
5880            answered,
5881            expected: answered,
5882            clean: true,
5883            progressed: false,
5884            vote_split: false,
5885            reconsideration: Vec::new(),
5886            verdict: None,
5887        }
5888    }
5889
5890    #[test]
5891    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
5892        let en = panel();
5893        assert!(en.contains("Task"), "{en}");
5894        assert!(en.contains("add retries to the uploader"), "{en}");
5895
5896        let mut state = run_state();
5897        state.config.graph.language = "ja".to_owned();
5898        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5899        assert!(ja.contains("タスク"), "{ja}");
5900        assert!(
5901            ja.contains("add retries to the uploader"),
5902            "the task itself is not translated: {ja}"
5903        );
5904    }
5905
5906    #[test]
5907    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
5908        // `run_state()` has no candidates, no tally and no reviews - exactly
5909        // the shape a run has before anything has judged or reviewed it, and
5910        // the panel must not print an empty box for either.
5911        let html = panel();
5912        assert!(!html.contains("What changed"), "{html}");
5913        assert!(!html.contains("Review verdict"), "{html}");
5914    }
5915
5916    #[test]
5917    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
5918        let mut state = run_state();
5919        state.candidates = vec![winning_candidate("")];
5920        state.tally = Some(uncontested_tally());
5921        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5922        assert!(
5923            !html.contains("What changed"),
5924            "an empty summary must not render an empty box: {html}"
5925        );
5926    }
5927
5928    #[test]
5929    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
5930        let mut state = run_state();
5931        state.candidates = vec![winning_candidate(
5932            "Added a retry loop around the uploader PUT call.",
5933        )];
5934        state.tally = Some(uncontested_tally());
5935        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5936        assert!(en.contains("What changed"), "{en}");
5937        assert!(
5938            en.contains("Added a retry loop around the uploader PUT call."),
5939            "{en}"
5940        );
5941
5942        state.config.graph.language = "ja".to_owned();
5943        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5944        assert!(ja.contains("変更内容"), "{ja}");
5945        assert!(
5946            ja.contains("Added a retry loop around the uploader PUT call."),
5947            "{ja}"
5948        );
5949    }
5950
5951    #[test]
5952    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
5953        let mut state = run_state();
5954        state.reviews = vec![
5955            review_round(
5956                1,
5957                vec![review_record(1, "alpha", "found a race, sent back")],
5958            ),
5959            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
5960        ];
5961        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5962        assert!(en.contains("Review verdict"), "{en}");
5963        assert!(en.contains("race is fixed, clean"), "{en}");
5964        assert!(
5965            !en.contains("found a race, sent back"),
5966            "only the round that actually cleared the merge should show: {en}"
5967        );
5968
5969        state.config.graph.language = "ja".to_owned();
5970        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
5971        assert!(ja.contains("レビューの結論"), "{ja}");
5972        assert!(ja.contains("レビュアー"), "{ja}");
5973        assert!(ja.contains("race is fixed, clean"), "{ja}");
5974    }
5975
5976    /// The `incomplete_review = "warn"` policy (see
5977    /// `graph::Runner::review_loop`) can push a `clean` round to
5978    /// `state.reviews` while one seat's own record still has `failed: Some`
5979    /// and an empty `summary` - a seat that never answered, not one that
5980    /// answered with nothing to say.
5981    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
5982        ReviewRecord {
5983            attempts: 0,
5984            reviewer,
5985            agent: agent.to_owned(),
5986            summary: String::new(),
5987            findings: Vec::new(),
5988            vote: None,
5989            failed: Some(reason.to_owned()),
5990            duration_ms: 0,
5991        }
5992    }
5993
5994    #[test]
5995    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
5996        let mut state = run_state();
5997        state.reviews = vec![review_round(
5998            1,
5999            vec![
6000                review_record(1, "alpha", "clean, nothing to add"),
6001                unanswered_review_record(2, "beta", "timed out"),
6002            ],
6003        )];
6004        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6005        assert!(en.contains("clean, nothing to add"), "{en}");
6006        assert!(
6007            en.contains("produced no answer: timed out"),
6008            "a seat that never answered must say so, not render a blank box: {en}"
6009        );
6010        assert!(
6011            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
6012            "no reviewer box may be left empty: {en}"
6013        );
6014
6015        state.config.graph.language = "ja".to_owned();
6016        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
6017        assert!(ja.contains("回答なし: timed out"), "{ja}");
6018    }
6019
6020    #[test]
6021    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
6022        let html = panel();
6023        assert!(!html.contains("<script"), "no script survives the csp");
6024        assert!(!html.contains("<form"), "form-action is 'none'");
6025        let pr = green_pr();
6026        assert_eq!(
6027            html.matches("http").count(),
6028            html.matches(pr.url.as_str()).count(),
6029            "the only http url in the panel is the pull request's own link"
6030        );
6031        assert!(
6032            html.contains("target=\"_blank\""),
6033            "the PR link must open in a new tab so it escapes the iframe"
6034        );
6035        assert!(
6036            html.contains("rel=\"noopener noreferrer\""),
6037            "external links opening in new tabs must carry noopener noreferrer"
6038        );
6039    }
6040
6041    #[test]
6042    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
6043        let html = panel();
6044        assert!(
6045            html.contains(">+</span>"),
6046            "an added line carries a `+` in the gutter, not only a background"
6047        );
6048        assert!(
6049            html.contains(">-</span>"),
6050            "a removed line carries a `-` in the gutter, not only a background"
6051        );
6052        assert!(
6053            html.contains(">new line</span>"),
6054            "the marker is moved to the gutter, so the body is printed once without it"
6055        );
6056    }
6057
6058    #[test]
6059    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
6060        let total = DIFF_MAX_LINES + 100;
6061        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
6062        let html = approval_panel(
6063            &run_state(),
6064            &green_pr(),
6065            NUMSTAT,
6066            &diff,
6067            &[],
6068            "feat: something long",
6069        );
6070        assert!(
6071            html.contains(&format!("100 of {total} diff lines omitted")),
6072            "the note must say exactly how much was cut"
6073        );
6074        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
6075        assert!(
6076            !html.contains(&format!("line {DIFF_MAX_LINES}")),
6077            "nothing past the threshold is rendered"
6078        );
6079        assert!(
6080            html.contains("/repo/magi"),
6081            "the note says where the rest is"
6082        );
6083    }
6084
6085    #[test]
6086    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
6087        let html = approval_panel(
6088            &run_state(),
6089            &green_pr(),
6090            "1\t2\tsrc/<b>&\"x\"'.rs",
6091            "",
6092            &[],
6093            "subject",
6094        );
6095        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
6096        assert!(
6097            !html.contains("<b>"),
6098            "an agent-influenced path must never become markup"
6099        );
6100    }
6101
6102    #[tokio::test]
6103    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
6104        let a = std::path::PathBuf::from("/repo/a");
6105        let b = std::path::PathBuf::from("/repo/b");
6106
6107        let held = repo_merge_lock(&a).lock_owned().await;
6108
6109        // A second, concurrent land run against the *same* repository must
6110        // wait - `try_lock` fails while `held` is alive.
6111        assert!(
6112            repo_merge_lock(&a).try_lock().is_err(),
6113            "a second merge into the same repository must not proceed concurrently"
6114        );
6115
6116        // A run against a *different* repository must not be blocked by it -
6117        // this is what keeps a slow rebase or `gh pr merge` in one
6118        // repository from also stalling a land-approval resume in another.
6119        assert!(
6120            repo_merge_lock(&b).try_lock().is_ok(),
6121            "a different repository's merge lock must be independent"
6122        );
6123
6124        drop(held);
6125        assert!(
6126            repo_merge_lock(&a).try_lock().is_ok(),
6127            "the lock is released once the holder is done"
6128        );
6129    }
6130
6131    fn repair_state(major: bool, reject: bool) -> RunState {
6132        let mut state = landing_state();
6133        let mut rec = review_record(1, "alpha", "precise rejection reason");
6134        rec.vote = Some(if reject {
6135            crate::verdict::ReviewVote::Reject
6136        } else {
6137            crate::verdict::ReviewVote::Approve
6138        });
6139        if major {
6140            rec.findings = contested().findings;
6141        }
6142        let mut round = review_round(1, vec![rec]);
6143        round.clean = false;
6144        round.blocking = usize::from(major);
6145        state.reviews.push(round);
6146        state.config.graph.review_rounds = 1;
6147        state
6148    }
6149
6150    #[test]
6151    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
6152        for (major, reject) in [(true, false), (false, true), (true, true)] {
6153            let state = repair_state(major, reject);
6154            assert!(repair_available(&state));
6155            assert_eq!(approval_choices(&state), [APPROVE, HOLD, REPAIR]);
6156        }
6157        assert!(!repair_available(&repair_state(false, false)));
6158        let mut clean = repair_state(true, false);
6159        clean.reviews[0].clean = true;
6160        assert_eq!(approval_choices(&clean), [APPROVE, HOLD]);
6161        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
6162    }
6163
6164    #[tokio::test]
6165    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
6166        let mut state = repair_state(true, false);
6167        let pr = green_pr();
6168        approval_gate(&mut state, &pr, "feat: x", None, "abc")
6169            .await
6170            .unwrap();
6171        let store = ask::Questions::open();
6172        let id = state.land_approval.as_ref().unwrap().question.clone();
6173        // Simulate an unanswered two-choice question created by an older build.
6174        store
6175            .update(&id, |q| {
6176                q.choices = vec![APPROVE.into(), HOLD.into()];
6177                Ok(())
6178            })
6179            .unwrap();
6180        let upgraded = store.get(&id).unwrap();
6181        assert_eq!(upgraded.choices, [APPROVE, HOLD, REPAIR]);
6182        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
6183        assert!(
6184            store
6185                .list()
6186                .iter()
6187                .any(|q| q.id == id && q.choices == upgraded.choices)
6188        );
6189        assert!(
6190            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
6191        );
6192        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
6193        store
6194            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
6195            .unwrap();
6196        let binding = state.land_approval.clone();
6197        assert_eq!(
6198            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6199                .await
6200                .unwrap(),
6201            ApprovalGate::Repair
6202        );
6203        assert!(state.land_approval.is_none());
6204        assert_eq!(state.merge_repairs.len(), 1);
6205        let request = &state.merge_repairs[0];
6206        assert_eq!(request.question, id);
6207        assert_eq!(request.head, "abc");
6208        assert_eq!(request.pr, pr.url);
6209        assert_eq!(
6210            request.reviews[0].reviews[0].summary,
6211            "precise rejection reason"
6212        );
6213        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
6214        assert_eq!(
6215            request.reviews[0].reviews[0].findings[0].detail,
6216            state.reviews[0].reviews[0].findings[0].detail
6217        );
6218        // A repeated consumption of the same answer never dispatches another repair.
6219        state.land_approval = binding;
6220        assert_eq!(
6221            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6222                .await
6223                .unwrap(),
6224            ApprovalGate::Repair
6225        );
6226        assert_eq!(state.merge_repairs.len(), 1);
6227    }
6228
6229    #[tokio::test]
6230    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
6231        for choice in [REPAIR, HOLD] {
6232            let mut state = repair_state(false, true);
6233            let observed = seen("abc", Checks::Green, "CLEAN", false);
6234            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
6235                .await
6236                .unwrap();
6237            let id = state.land_approval.as_ref().unwrap().question.clone();
6238            ask::Questions::open()
6239                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
6240                .unwrap();
6241            let forge = Scripted::new(
6242                vec![observed],
6243                if choice == REPAIR {
6244                    vec![(true, "")]
6245                } else {
6246                    vec![]
6247                },
6248            );
6249            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
6250                .await
6251                .unwrap();
6252            assert!(
6253                forge
6254                    .argvs()
6255                    .iter()
6256                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
6257            );
6258            assert!(!forge.calls().contains(&"fix"));
6259            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
6260            assert_ne!(state.status, RunStatus::Merged);
6261        }
6262    }
6263
6264    #[test]
6265    fn only_the_merge_choice_merges_and_silence_holds() {
6266        let table = [
6267            (None, Approval::Hold),
6268            (Some("merge"), Approval::Merge),
6269            (Some(" merge\n"), Approval::Merge),
6270            (Some("hold"), Approval::Hold),
6271            (Some(""), Approval::Hold),
6272            (Some("yes"), Approval::Hold),
6273        ];
6274        for (answer, want) in table {
6275            assert_eq!(
6276                approval(answer),
6277                want,
6278                "answer {answer:?} must resolve to {want:?}"
6279            );
6280        }
6281    }
6282
6283    #[tokio::test]
6284    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
6285        let mut state = landing_state();
6286        state.config.graph.land_approval = true;
6287        let pr = green_pr();
6288
6289        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6290            .await
6291            .unwrap();
6292        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
6293        assert!(
6294            !state.parked,
6295            "approval_gate itself never sets `parked`; only its caller does"
6296        );
6297
6298        let store = ask::Questions::open();
6299        let filed: Vec<_> = store
6300            .list()
6301            .into_iter()
6302            .filter(|q| q.run == state.id)
6303            .collect();
6304        assert_eq!(filed.len(), 1, "exactly one question is filed");
6305        assert_eq!(filed[0].node, APPROVAL_NODE);
6306        assert_eq!(filed[0].choices, vec![APPROVE.to_owned(), HOLD.to_owned()]);
6307        assert!(filed[0].status.open());
6308
6309        // A second visit - standing in for a resumed run whose slot the
6310        // daemon handed to something else while nobody had answered - must
6311        // find the same question rather than filing a second one.
6312        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
6313            .await
6314            .unwrap();
6315        assert_eq!(again, ApprovalGate::Pending);
6316        let still_one = store
6317            .list()
6318            .into_iter()
6319            .filter(|q| q.run == state.id)
6320            .count();
6321        assert_eq!(
6322            still_one, 1,
6323            "asking twice must not double-file the question"
6324        );
6325    }
6326
6327    #[tokio::test]
6328    async fn approving_the_existing_question_is_read_back_as_approved() {
6329        crate::run::pin_test_home();
6330        let mut state = run_state();
6331        state.config.graph.land_approval = true;
6332        let pr = green_pr();
6333        assert_eq!(
6334            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6335                .await
6336                .unwrap(),
6337            ApprovalGate::Pending
6338        );
6339
6340        let store = ask::Questions::open();
6341        let mut q = store
6342            .list()
6343            .into_iter()
6344            .find(|q| q.run == state.id)
6345            .expect("filed above");
6346        let talks = crate::talk::Talks::open();
6347        let cfg = crate::config::Config {
6348            agents: vec![crate::config::AgentSpec {
6349                id: "mock".into(),
6350                kind: crate::config::AgentKind::Command,
6351                model: None,
6352                command: vec!["true".into()],
6353                extra_args: Vec::new(),
6354                env: Default::default(),
6355                prompt_delivery: None,
6356            }],
6357            ..crate::config::Config::default()
6358        };
6359        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
6360        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
6361        crate::talk::drain(&mut talk, &talks).unwrap();
6362        assert_eq!(
6363            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6364                .await
6365                .unwrap(),
6366            ApprovalGate::Pending,
6367            "consultation cannot approve or hold the merge"
6368        );
6369        q = store.get(&q.id).unwrap();
6370        assert!(q.answer.is_none());
6371        talk.turns.push(crate::talk::Turn {
6372            breaks: Some(Vec::new()),
6373            messages: Vec::new(),
6374            posted_by: None,
6375            who: crate::talk::Who::Operator,
6376            body: "Merge this pull request now".into(),
6377            at: Timestamp::now(),
6378            attachments: Vec::new(),
6379            usage: None,
6380        });
6381        talks.put(&mut talk).unwrap();
6382        crate::consult::validate_answer(
6383            &q,
6384            &talks,
6385            &talk.id,
6386            crate::queue::CHAT_NODE,
6387            APPROVE,
6388            Some("Merge this pull request now"),
6389        )
6390        .unwrap();
6391        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6392        store.put(&mut q).unwrap();
6393
6394        assert_eq!(
6395            approval_gate(&mut state, &pr, "feat: x", None, "abc")
6396                .await
6397                .unwrap(),
6398            ApprovalGate::Approved
6399        );
6400    }
6401
6402    #[tokio::test]
6403    async fn holding_or_abandoning_the_existing_question_is_read_back_as_held() {
6404        crate::run::pin_test_home();
6405        let store = ask::Questions::open();
6406
6407        let mut held_state = run_state();
6408        held_state.config.graph.land_approval = true;
6409        let pr = green_pr();
6410        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6411            .await
6412            .unwrap();
6413        let mut q = store
6414            .list()
6415            .into_iter()
6416            .find(|q| q.run == held_state.id)
6417            .expect("filed above");
6418        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
6419        store.put(&mut q).unwrap();
6420        assert_eq!(
6421            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
6422                .await
6423                .unwrap(),
6424            ApprovalGate::Held
6425        );
6426
6427        let mut abandoned_state = run_state();
6428        abandoned_state.config.graph.land_approval = true;
6429        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6430            .await
6431            .unwrap();
6432        let mut q = store
6433            .list()
6434            .into_iter()
6435            .find(|q| q.run == abandoned_state.id)
6436            .expect("filed above");
6437        q.abandon("no answer within the timeout");
6438        store.put(&mut q).unwrap();
6439        assert_eq!(
6440            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
6441                .await
6442                .unwrap(),
6443            ApprovalGate::Held,
6444            "silence must never merge"
6445        );
6446    }
6447
6448    fn contested() -> ContestedHandoff {
6449        let finding = |id: &str, n: u32| crate::verdict::Finding {
6450            id: id.to_owned(),
6451            severity: crate::verdict::Severity::Major,
6452            file: Some("src/a.rs".to_owned()),
6453            line: Some(n),
6454            title: format!("problem {id}"),
6455            detail: String::new(),
6456        };
6457        ContestedHandoff {
6458            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
6459            rejecters: vec![(1, "alpha".to_owned())],
6460        }
6461    }
6462
6463    #[test]
6464    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
6465        let mut state = run_state();
6466        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
6467        state.contested_handoff = Some(contested());
6468        assert!(contested_to_ask(&state).is_some());
6469        state.config.graph.hold_contested_merge = false;
6470        assert!(
6471            contested_to_ask(&state).is_none(),
6472            "the switch restores today"
6473        );
6474    }
6475
6476    #[test]
6477    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
6478        let q = ask::Question::new(
6479            "run-1".to_owned(),
6480            APPROVAL_NODE.to_owned(),
6481            "land".to_owned(),
6482            "Merge?".to_owned(),
6483            String::new(),
6484            vec![APPROVE.to_owned(), HOLD.to_owned()],
6485        );
6486        let none = deputy_brief(&q, None);
6487        assert!(none.contains("could not be read"), "{none}");
6488        assert!(none.contains("Silence is a hold"), "{none}");
6489
6490        let mut state = run_state();
6491        state.pr = Some(crate::run::PrRecord {
6492            url: "https://example.test/pull/7".to_owned(),
6493            number: 7,
6494            state: "open".to_owned(),
6495            checks: "green".to_owned(),
6496            round: 0,
6497            rounds: 3,
6498            red_at_merge: Vec::new(),
6499        });
6500        state.contested_handoff = Some(contested());
6501        let b = deputy_brief(&q, Some(&state));
6502        assert!(b.contains("https://example.test/pull/7"), "{b}");
6503        assert!(b.contains("R3-1-1") && b.contains("src/a.rs:1"), "{b}");
6504        assert!(b.contains("#1"), "the rejecting seat: {b}");
6505        state.contested_handoff = None;
6506        assert!(deputy_brief(&q, Some(&state)).contains("not recorded as contested"));
6507    }
6508
6509    #[test]
6510    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
6511        for lang in ["en", "ja"] {
6512            let mut cfg = crate::config::Config::default();
6513            cfg.graph.language = lang.to_owned();
6514            let w = words(&cfg.graph.language);
6515            let text = w.approval_detail(
6516                "https://github.com/yukimemi/magi/pull/42",
6517                "main",
6518                "feat: x",
6519                Some(&contested()),
6520            );
6521            assert!(text.contains("pull/42"), "{text}");
6522            assert!(
6523                text.contains("R3-1-1 Major src/a.rs:1: problem R3-1-1"),
6524                "{text}"
6525            );
6526            assert!(text.contains("R3-1-5"), "{text}");
6527            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
6528            assert!(text.contains("2"), "the rest are counted: {text}");
6529            assert!(text.contains("#1 (alpha)"), "{text}");
6530        }
6531        let plain = words("en").approval_detail("u", "main", "s", None);
6532        assert!(!plain.contains("reject"), "{plain}");
6533    }
6534
6535    #[tokio::test]
6536    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
6537        crate::run::pin_test_home();
6538        let mut state = run_state();
6539        state.config.graph.land_approval = false;
6540        state.contested_handoff = Some(contested());
6541        let pr = green_pr();
6542        let c = contested_to_ask(&state);
6543        assert_eq!(
6544            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6545                .await
6546                .unwrap(),
6547            ApprovalGate::Pending,
6548            "silence is a hold"
6549        );
6550        let store = ask::Questions::open();
6551        let filed: Vec<_> = store
6552            .list()
6553            .into_iter()
6554            .filter(|q| q.run == state.id)
6555            .collect();
6556        assert_eq!(filed.len(), 1);
6557        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
6558
6559        assert_eq!(
6560            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6561                .await
6562                .unwrap(),
6563            ApprovalGate::Pending
6564        );
6565        let mut q = store
6566            .list()
6567            .into_iter()
6568            .find(|q| q.run == state.id)
6569            .unwrap();
6570        assert_eq!(q.id, filed[0].id, "the same question after a resume");
6571        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
6572        store.put(&mut q).unwrap();
6573        assert_eq!(
6574            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
6575                .await
6576                .unwrap(),
6577            ApprovalGate::Approved
6578        );
6579    }
6580
6581    #[test]
6582    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
6583        let rows = parse_numstat(NUMSTAT);
6584        assert_eq!(
6585            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
6586            ["src/web.rs", "src/land.rs", "assets/logo.png"]
6587        );
6588        assert_eq!(rows[2].added, None, "a binary file has no line counts");
6589    }
6590    #[test]
6591    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
6592        // Reported from a real run: the merge question arrived in English on a
6593        // repository with `language = "ja"`. magi's own strings have to follow
6594        // that setting too - "it is a literal in Rust" is not an answer.
6595        let mut state = run_state();
6596        state.config.graph.language = "ja".to_owned();
6597        let pr = green_pr();
6598        let commits = ["c1".to_owned()];
6599
6600        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6601        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
6602        assert!(ja.contains("squash されるコミット"), "{ja}");
6603        assert!(ja.contains("レビューコメント"), "{ja}");
6604        assert!(ja.contains("差分"), "{ja}");
6605        assert!(
6606            !ja.contains("Commits being squashed"),
6607            "no English left over"
6608        );
6609
6610        let w = words("ja");
6611        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
6612        assert!(
6613            w.approval_detail("http://x/1", "main", "feat: x", None)
6614                .contains("パネル")
6615        );
6616
6617        // The evidence itself is language-neutral and must survive either way.
6618        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
6619        assert!(ja.contains("feat: x"), "nor is the merge subject");
6620
6621        // English stays the default, and a language magi cannot check falls
6622        // back to it rather than shipping a guess.
6623        state.config.graph.language = "en".to_owned();
6624        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
6625        assert!(en.contains("Commits being squashed"), "{en}");
6626        assert_eq!(words("Klingon").html_lang, "en");
6627    }
6628
6629    /// A `gh pr list` result naming exactly one pull request whose base and
6630    /// merge time both fit the run is exactly the case
6631    /// [`find_external_merge`] exists to act on.
6632    #[test]
6633    fn pick_open_pr_classifies_by_count_and_base() {
6634        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
6635        assert_eq!(
6636            pick_open_pr(one, "main").unwrap(),
6637            OpenPr::One {
6638                url: "https://x/pull/58".into(),
6639                title: "t".into()
6640            }
6641        );
6642        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
6643        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
6644        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
6645                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
6646        assert_eq!(
6647            pick_open_pr(two, "main").unwrap(),
6648            OpenPr::Many(vec!["u1".into(), "u2".into()])
6649        );
6650        // A fork's same-named branch is not a pull request for this head.
6651        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
6652        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
6653        assert!(pick_open_pr("not json", "main").is_err());
6654        // An incomplete record is an error, never "nothing open".
6655        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
6656        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
6657    }
6658
6659    #[test]
6660    fn pick_merged_pr_picks_the_unique_match() {
6661        let json = r#"[
6662            {"url": "https://github.com/o/r/pull/42", "number": 42,
6663             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
6664        ]"#;
6665        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6666        let found = pick_merged_pr(json, "main", created_at)
6667            .expect("valid json")
6668            .expect("one unambiguous match");
6669        assert_eq!(found.url, "https://github.com/o/r/pull/42");
6670        assert_eq!(found.number, 42);
6671    }
6672
6673    /// Two candidates surviving the filter is exactly as uninformative as
6674    /// zero — a branch name can be reused across runs — so neither is
6675    /// preferred over the other and nothing is recorded automatically.
6676    #[test]
6677    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
6678        let json = r#"[
6679            {"url": "https://github.com/o/r/pull/42", "number": 42,
6680             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
6681            {"url": "https://github.com/o/r/pull/43", "number": 43,
6682             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
6683        ]"#;
6684        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6685        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6686    }
6687
6688    /// A pull request that targets a different base branch cannot be this
6689    /// run's, whatever its head branch is named — a reused branch name from
6690    /// an unrelated task must not be recorded as this run's merge.
6691    #[test]
6692    fn pick_merged_pr_ignores_a_different_base_branch() {
6693        let json = r#"[
6694            {"url": "https://github.com/o/r/pull/42", "number": 42,
6695             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
6696        ]"#;
6697        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6698        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6699    }
6700
6701    /// A pull request merged before this run was even created cannot be this
6702    /// run's winner, no matter how its head branch is spelled.
6703    #[test]
6704    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
6705        let json = r#"[
6706            {"url": "https://github.com/o/r/pull/42", "number": 42,
6707             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
6708        ]"#;
6709        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
6710        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
6711    }
6712
6713    #[test]
6714    fn slug_of_pr_url_reads_host_owner_and_repo() {
6715        assert_eq!(
6716            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
6717            Some("github.com/yukimemi/shun")
6718        );
6719    }
6720
6721    #[test]
6722    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
6723        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
6724        assert_eq!(slug_of_pr_url("not a url at all"), None);
6725        assert_eq!(slug_of_pr_url("https://github.com"), None);
6726    }
6727
6728    #[test]
6729    fn slug_of_repo_url_reads_host_owner_and_repo() {
6730        assert_eq!(
6731            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
6732            Some("github.com/yukimemi/magi")
6733        );
6734        assert_eq!(slug_of_repo_url("https://github.com"), None);
6735    }
6736
6737    #[test]
6738    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
6739        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
6740            .expect("same repo, different case");
6741    }
6742
6743    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
6744    /// own in-progress run and rewrote its status from a pull request in a
6745    /// completely different repository. This is the guard that must catch
6746    /// that even when an explicit (but wrong) id is given.
6747    #[test]
6748    fn ensure_same_repo_refuses_a_different_repo() {
6749        let err =
6750            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
6751        let msg = format!("{err:#}");
6752        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6753        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
6754    }
6755
6756    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
6757    /// instance mirroring a `github.com` repository's name, say) must not be
6758    /// treated as the same repository just because the trailing path
6759    /// matches.
6760    #[test]
6761    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
6762        let err = ensure_same_repo(
6763            "github.com/yukimemi/magi",
6764            "github.example.com/yukimemi/magi",
6765        )
6766        .unwrap_err();
6767        let msg = format!("{err:#}");
6768        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
6769        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
6770    }
6771
6772    /// No winner decided yet means there is no branch to ask GitHub about at
6773    /// all — `find_external_merge` must return `None` without ever spawning
6774    /// `gh`, which this proves by never providing a real repository to spawn
6775    /// it in.
6776    #[tokio::test]
6777    async fn find_external_merge_returns_none_without_a_winner() {
6778        let state = RunState::new(
6779            PathBuf::from("/no/such/repo"),
6780            "main".to_owned(),
6781            "0000000000000000000000000000000000000000".to_owned(),
6782            "irrelevant".to_owned(),
6783            crate::config::Config::default(),
6784        );
6785        assert_eq!(find_external_merge(&state).await.unwrap(), None);
6786    }
6787
6788    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
6789        let mut run = RunState::new(
6790            PathBuf::from(repo),
6791            "main".to_owned(),
6792            "abcdef1234".to_owned(),
6793            "x".to_owned(),
6794            crate::config::Config::default(),
6795        );
6796        run.id = id.to_owned();
6797        run.status = status;
6798        run.pr = Some(crate::run::PrRecord {
6799            number: url.rsplit('/').next().unwrap().parse().unwrap(),
6800            url: url.to_owned(),
6801            state: state.to_owned(),
6802            checks: "red".to_owned(),
6803            round: 0,
6804            rounds: 2,
6805            red_at_merge: Vec::new(),
6806        });
6807        run.save_under(home).unwrap();
6808    }
6809
6810    fn recorded(home: &Path, id: &str) -> String {
6811        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
6812        serde_json::from_str::<RunState>(&body)
6813            .unwrap()
6814            .pr
6815            .unwrap()
6816            .state
6817    }
6818
6819    const PR: &str = "https://github.com/o/r/pull/7";
6820
6821    #[test]
6822    fn write_through_updates_predecessors_and_siblings_only() {
6823        let tmp = tempfile::tempdir().unwrap();
6824        let h = tmp.path();
6825        pr_run(
6826            h,
6827            "20261004-100000-aaaa",
6828            "/repo/r",
6829            RunStatus::Superseded,
6830            PR,
6831            "open",
6832        );
6833        pr_run(
6834            h,
6835            "20261004-100100-bbbb",
6836            "/repo/r",
6837            RunStatus::Blocked,
6838            PR,
6839            "open",
6840        );
6841        // Not terminal: a driver may be writing it.
6842        pr_run(
6843            h,
6844            "20261004-100200-cccc",
6845            "/repo/r",
6846            RunStatus::Landing,
6847            PR,
6848            "open",
6849        );
6850        // Another repository's pull request with the same number.
6851        pr_run(
6852            h,
6853            "20261004-100300-dddd",
6854            "/repo/other",
6855            RunStatus::Blocked,
6856            "https://github.com/o/other/pull/7",
6857            "open",
6858        );
6859        // A different pull request of the same repository.
6860        pr_run(
6861            h,
6862            "20261004-100400-eeee",
6863            "/repo/r",
6864            RunStatus::Blocked,
6865            "https://github.com/o/r/pull/8",
6866            "open",
6867        );
6868        pr_run(
6869            h,
6870            "20261004-100500-ffff",
6871            "/repo/r",
6872            RunStatus::Merged,
6873            PR,
6874            "open",
6875        );
6876        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
6877
6878        assert_eq!(
6879            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6880            2
6881        );
6882        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6883        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
6884        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6885        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6886        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6887        // The source's own record is the caller's to write.
6888        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
6889        // Idempotent.
6890        assert_eq!(
6891            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
6892            0
6893        );
6894        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
6895        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
6896    }
6897
6898    #[test]
6899    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
6900        let tmp = tempfile::tempdir().unwrap();
6901        let h = tmp.path();
6902        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
6903        pr_run(
6904            h,
6905            "20261004-100000-aaaa",
6906            "/repo/r",
6907            RunStatus::Superseded,
6908            &url(1),
6909            "open",
6910        );
6911        pr_run(
6912            h,
6913            "20261004-100100-bbbb",
6914            "/repo/r",
6915            RunStatus::Blocked,
6916            &url(2),
6917            "open",
6918        );
6919        pr_run(
6920            h,
6921            "20261004-100200-cccc",
6922            "/repo/r",
6923            RunStatus::Ready,
6924            &url(3),
6925            "open",
6926        );
6927        pr_run(
6928            h,
6929            "20261004-100300-dddd",
6930            "/repo/r",
6931            RunStatus::Ready,
6932            &url(4),
6933            "open",
6934        );
6935        pr_run(
6936            h,
6937            "20261004-100400-eeee",
6938            "/repo/r",
6939            RunStatus::Implementing,
6940            &url(1),
6941            "open",
6942        );
6943        assert_eq!(stale_open_prs(h).len(), 4);
6944
6945        let mut known = BTreeMap::new();
6946        known.insert(url(1), PrLifecycle::Merged);
6947        known.insert(url(2), PrLifecycle::Closed);
6948        known.insert(url(3), PrLifecycle::Open);
6949        // #4: the forge could not be read, so it has no answer.
6950        assert_eq!(apply_pr_states(h, &known), 2);
6951        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
6952        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
6953        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
6954        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
6955        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
6956        assert_eq!(apply_pr_states(h, &known), 0);
6957    }
6958
6959    // --- the land loop against a scripted forge -------------------------
6960
6961    use std::collections::VecDeque;
6962    use std::sync::Mutex;
6963
6964    /// Answers views from a script (the last one repeats), merges from a
6965    /// queue, and records every call so a test can assert the order.
6966    struct Scripted {
6967        views: Mutex<VecDeque<Seen>>,
6968        merges: Mutex<VecDeque<(bool, String)>>,
6969        fix: Mutex<Option<Fixed>>,
6970        log: Mutex<Vec<&'static str>>,
6971        argvs: Mutex<Vec<Vec<String>>>,
6972        required: Mutex<Option<BTreeSet<String>>>,
6973        /// Set once a merge answered ok: the forge then reports `merged`,
6974        /// unless `queued` says the merge only entered a queue.
6975        merged: Mutex<bool>,
6976        queued: Mutex<bool>,
6977        /// Views fail once a merge answered ok.
6978        unreadable_after_merge: Mutex<bool>,
6979        /// Workflow runs asked to rerun, and whether that call fails.
6980        reruns: Mutex<Vec<String>>,
6981        rerun_fails: Mutex<bool>,
6982        /// Unix seconds; every poll moves it on by `POLL`.
6983        clock: Mutex<i64>,
6984    }
6985
6986    impl Scripted {
6987        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
6988            Self {
6989                views: Mutex::new(views.into()),
6990                merges: Mutex::new(
6991                    merges
6992                        .into_iter()
6993                        .map(|(ok, m)| (ok, m.to_owned()))
6994                        .collect(),
6995                ),
6996                fix: Mutex::new(None),
6997                log: Mutex::new(Vec::new()),
6998                argvs: Mutex::new(Vec::new()),
6999                required: Mutex::new(None),
7000                merged: Mutex::new(false),
7001                queued: Mutex::new(false),
7002                unreadable_after_merge: Mutex::new(false),
7003                reruns: Mutex::new(Vec::new()),
7004                rerun_fails: Mutex::new(false),
7005                clock: Mutex::new(1_000),
7006            }
7007        }
7008        fn argvs(&self) -> Vec<Vec<String>> {
7009            self.argvs.lock().unwrap().clone()
7010        }
7011        fn calls(&self) -> Vec<&'static str> {
7012            self.log.lock().unwrap().clone()
7013        }
7014    }
7015
7016    impl Forge for Scripted {
7017        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
7018            self.log.lock().unwrap().push("view");
7019            if *self.unreadable_after_merge.lock().unwrap()
7020                && !self.argvs.lock().unwrap().is_empty()
7021            {
7022                anyhow::bail!("forge unreachable");
7023            }
7024            let mut v = self.views.lock().unwrap();
7025            let mut seen = if v.len() > 1 {
7026                v.pop_front().unwrap()
7027            } else {
7028                v[0].clone()
7029            };
7030            if *self.merged.lock().unwrap() {
7031                seen.pr.state = PrLifecycle::Merged;
7032            }
7033            Ok(seen)
7034        }
7035        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
7036            self.log.lock().unwrap().push("merge");
7037            self.argvs.lock().unwrap().push(argv.to_vec());
7038            let out = self
7039                .merges
7040                .lock()
7041                .unwrap()
7042                .pop_front()
7043                .expect("unscripted merge");
7044            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
7045            {
7046                *self.merged.lock().unwrap() = true;
7047            }
7048            Ok(out)
7049        }
7050        async fn poll(&self) {
7051            self.log.lock().unwrap().push("poll");
7052            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
7053        }
7054        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
7055            self.log.lock().unwrap().push("rerun");
7056            self.reruns.lock().unwrap().push(run.to_owned());
7057            if *self.rerun_fails.lock().unwrap() {
7058                anyhow::bail!("gh run rerun: no");
7059            }
7060            Ok(())
7061        }
7062        fn now(&self) -> i64 {
7063            *self.clock.lock().unwrap()
7064        }
7065        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
7066            self.required.lock().unwrap().clone()
7067        }
7068        async fn fix(
7069            &self,
7070            _state: &mut RunState,
7071            _pr: &PrState,
7072            _round: usize,
7073            _budget: usize,
7074            _reason: &str,
7075            _logs: &str,
7076        ) -> Result<Fixed> {
7077            self.log.lock().unwrap().push("fix");
7078            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
7079        }
7080    }
7081
7082    const REFUSED: &str =
7083        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
7084
7085    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
7086        let mut pr = green_pr();
7087        pr.checks = checks;
7088        pr.blocking = Blocking::of(merge_state);
7089        if !comments {
7090            pr.review_comments.clear();
7091        }
7092        Seen {
7093            pr,
7094            title: "feat: x".to_owned(),
7095            failing_urls: Vec::new(),
7096            head: head.to_owned(),
7097            rollup_head: head.to_owned(),
7098            merge_state: merge_state.to_owned(),
7099            contexts: Vec::new(),
7100            base: "main".to_owned(),
7101        }
7102    }
7103
7104    fn landing_state() -> RunState {
7105        crate::run::pin_test_home();
7106        let mut state = run_state();
7107        state.config.graph.land_approval = false;
7108        state
7109    }
7110
7111    #[test]
7112    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
7113        assert!(!awaiting_new_head(None, "aaa"));
7114        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
7115        assert!(awaiting_new_head(Some("abc123"), "def456"));
7116        assert!(awaiting_new_head(Some("abc123"), ""));
7117    }
7118
7119    #[test]
7120    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
7121        let open = |c, m: &str| seen("a", c, m, false);
7122        let table = [
7123            (None, false, Refused::Pending),
7124            (
7125                Some(open(Checks::Pending, "BLOCKED")),
7126                false,
7127                Refused::Pending,
7128            ),
7129            (
7130                Some(open(Checks::Unknown, "BLOCKED")),
7131                false,
7132                Refused::Pending,
7133            ),
7134            (
7135                Some(open(Checks::Green, "UNKNOWN")),
7136                false,
7137                Refused::Pending,
7138            ),
7139            (Some(open(Checks::Green, "")), false, Refused::Pending),
7140            (
7141                Some(open(Checks::Green, "BLOCKED")),
7142                false,
7143                Refused::Recheck,
7144            ),
7145            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
7146        ];
7147        for (after, rechecked, want) in table {
7148            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
7149        }
7150        let mut closed = open(Checks::Green, "CLEAN");
7151        closed.pr.state = PrLifecycle::Closed;
7152        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
7153    }
7154
7155    #[tokio::test]
7156    async fn a_normal_landing_merges_on_the_first_look() {
7157        let mut state = landing_state();
7158        let forge = Scripted::new(
7159            vec![seen("a", Checks::Green, "CLEAN", false)],
7160            vec![(true, "")],
7161        );
7162        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7163            .await
7164            .unwrap();
7165        // One fresh read, then the head-bound merge.
7166        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7167        assert_eq!(state.status, RunStatus::Merged);
7168    }
7169
7170    #[tokio::test]
7171    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
7172        let mut state = landing_state();
7173        let forge = Scripted::new(
7174            vec![seen("a", Checks::Green, "CLEAN", false)],
7175            std::iter::repeat_n((true, ""), 100).collect(),
7176        );
7177        *forge.queued.lock().unwrap() = true;
7178        let task = async {
7179            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7180                .await
7181                .unwrap();
7182        };
7183        // Still open after the command succeeded: it keeps watching and
7184        // never records a merge (it stops at the wait ceiling instead).
7185        task.await;
7186        assert_ne!(state.status, RunStatus::Merged);
7187    }
7188
7189    #[tokio::test]
7190    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
7191        let mut state = landing_state();
7192        let forge = Scripted::new(
7193            vec![seen("a", Checks::Green, "CLEAN", false)],
7194            std::iter::repeat_n((true, ""), 100).collect(),
7195        );
7196        *forge.unreadable_after_merge.lock().unwrap() = true;
7197        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7198            .await
7199            .ok();
7200        assert_ne!(state.status, RunStatus::Merged);
7201    }
7202
7203    #[tokio::test]
7204    async fn a_task_public_head_is_awaited_on_land_entry() {
7205        let mut state = landing_state();
7206        state.pr_head = Some("magi/task-abcd-1234".into());
7207        state.pr_pushed_head = Some("new".into());
7208        let forge = Scripted::new(
7209            vec![
7210                seen("old", Checks::Green, "CLEAN", false),
7211                seen("new", Checks::Green, "CLEAN", false),
7212            ],
7213            vec![(true, "")],
7214        );
7215        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7216            .await
7217            .unwrap();
7218        assert_eq!(
7219            forge.calls(),
7220            ["view", "poll", "view", "view", "merge", "view"]
7221        );
7222        assert_eq!(state.status, RunStatus::Merged);
7223    }
7224
7225    #[tokio::test]
7226    async fn a_superseded_public_head_owner_cannot_reenter_land() {
7227        let mut state = landing_state();
7228        state.pr_taken_over_by = Some("next".into());
7229        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
7230        assert!(
7231            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7232                .await
7233                .is_err()
7234        );
7235        assert!(forge.calls().is_empty());
7236    }
7237
7238    #[tokio::test]
7239    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
7240        let mut state = landing_state();
7241        let forge = Scripted::new(
7242            vec![
7243                seen("old", Checks::Green, "CLEAN", true),
7244                // The forge has not moved to the new head yet: still green.
7245                seen("old", Checks::Green, "CLEAN", true),
7246                seen("new", Checks::Pending, "BLOCKED", true),
7247                seen("new", Checks::Green, "CLEAN", true),
7248            ],
7249            vec![(true, "")],
7250        );
7251        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7252            head: "NEW".to_owned(),
7253        });
7254        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7255            .await
7256            .unwrap();
7257        assert_eq!(
7258            forge.calls(),
7259            [
7260                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
7261                "view"
7262            ]
7263        );
7264        assert_eq!(state.status, RunStatus::Merged);
7265    }
7266
7267    #[tokio::test]
7268    async fn a_head_that_never_arrives_stops_naming_both_commits() {
7269        let mut state = landing_state();
7270        let forge = Scripted::new(
7271            vec![
7272                seen("old", Checks::Green, "CLEAN", true),
7273                seen("someone-elses", Checks::Green, "CLEAN", true),
7274            ],
7275            vec![],
7276        );
7277        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7278            head: "mine".to_owned(),
7279        });
7280        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7281            .await
7282            .unwrap();
7283        assert!(!forge.calls().contains(&"merge"));
7284        let why = state.merge.as_ref().unwrap().detail.clone();
7285        assert!(
7286            why.contains("mine") && why.contains("someone-elses"),
7287            "{why}"
7288        );
7289        assert_eq!(state.status, RunStatus::Blocked);
7290    }
7291
7292    #[tokio::test]
7293    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
7294        let mut state = landing_state();
7295        let forge = Scripted::new(
7296            vec![
7297                seen("a", Checks::Green, "CLEAN", false),
7298                seen("a", Checks::Green, "CLEAN", false),
7299                seen("a", Checks::Pending, "BLOCKED", false),
7300                seen("a", Checks::Pending, "BLOCKED", false),
7301                seen("a", Checks::Green, "CLEAN", false),
7302            ],
7303            vec![(false, REFUSED), (true, "")],
7304        );
7305        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7306            .await
7307            .unwrap();
7308        assert_eq!(
7309            forge.calls(),
7310            [
7311                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
7312                "view"
7313            ]
7314        );
7315        assert_eq!(state.status, RunStatus::Merged);
7316    }
7317
7318    #[tokio::test]
7319    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
7320        let mut state = landing_state();
7321        let forge = Scripted::new(
7322            vec![
7323                seen("a", Checks::Green, "CLEAN", false),
7324                seen("a", Checks::Green, "BLOCKED", false),
7325            ],
7326            vec![(false, REFUSED), (false, REFUSED)],
7327        );
7328        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7329            .await
7330            .unwrap();
7331        // One re-look is allowed for the forge's own lag, then it is final.
7332        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
7333        let why = state.merge.as_ref().unwrap().detail.clone();
7334        assert!(
7335            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
7336            "{why}"
7337        );
7338        assert_eq!(state.status, RunStatus::Blocked);
7339    }
7340
7341    #[test]
7342    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
7343        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
7344        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
7345        // The pull request is still on the commit before the push.
7346        assert_eq!(bound_head("old", "old", Some("new")), None);
7347        // The checks are the previous commit's.
7348        assert_eq!(bound_head("new", "old", Some("new")), None);
7349        assert_eq!(bound_head("new", "old", None), None);
7350        // Nothing readable.
7351        assert_eq!(bound_head("", "", None), None);
7352        assert_eq!(bound_head("", "", Some("new")), None);
7353        assert_eq!(bound_head("abc", "", None), None);
7354    }
7355
7356    fn view_json(head: &str) -> String {
7357        format!(
7358            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
7359            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
7360            "reviews":[],"comments":[]}}"#
7361        )
7362    }
7363
7364    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
7365        format!(
7366            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
7367            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
7368            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
7369            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
7370        )
7371    }
7372
7373    #[test]
7374    fn rollup_is_bound_to_the_commit_in_the_same_node() {
7375        // The view already points at the new head, but the node still answers
7376        // for the old commit with its red check: the head stays unbound.
7377        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
7378        assert_eq!(s.rollup_head, "old");
7379        assert_eq!(s.pr.checks, Checks::Red);
7380        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
7381        assert_eq!(s.failing_urls.len(), 1);
7382    }
7383
7384    #[test]
7385    fn checks_come_from_the_node_not_the_view() {
7386        let view = view_json("new").replace(
7387            r#""reviews""#,
7388            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
7389        );
7390        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
7391        assert_eq!(s.pr.checks, Checks::Green);
7392        assert!(s.pr.failing.is_empty());
7393        assert_eq!(
7394            bound_head(&s.head, &s.rollup_head, Some("new")),
7395            Some("new")
7396        );
7397    }
7398
7399    #[test]
7400    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
7401        for node in [
7402            None,
7403            Some("not json".to_owned()),
7404            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
7405            Some(node_json("new", "SUCCESS", true)),
7406        ] {
7407            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
7408            assert!(s.rollup_head.is_empty());
7409            assert_eq!(s.pr.checks, Checks::Unknown);
7410            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
7411        }
7412    }
7413
7414    #[test]
7415    fn the_merge_command_is_pinned_to_the_observed_head() {
7416        let argv = merge_argv_at(7, "feat: x", "deadbeef");
7417        let at = argv
7418            .iter()
7419            .position(|a| a == "--match-head-commit")
7420            .unwrap();
7421        assert_eq!(argv[at + 1], "deadbeef");
7422    }
7423
7424    #[tokio::test]
7425    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
7426        let mut state = landing_state();
7427        // The pull request is on the pushed head but the rollup is still the
7428        // previous commit's red, non-required result.
7429        let mut stale = seen("new", Checks::Red, "CLEAN", false);
7430        stale.rollup_head = "old".to_owned();
7431        let forge = Scripted::new(
7432            vec![seen("old", Checks::Green, "CLEAN", true), stale],
7433            vec![],
7434        );
7435        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7436            head: "new".to_owned(),
7437        });
7438        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7439            .await
7440            .unwrap();
7441        assert!(!forge.calls().contains(&"merge"));
7442        assert_eq!(state.status, RunStatus::Blocked);
7443        let why = state.merge.as_ref().unwrap().detail.clone();
7444        assert!(why.contains("new") && why.contains("old"), "{why}");
7445    }
7446
7447    #[test]
7448    fn a_refusal_read_against_another_commits_checks_is_pending() {
7449        let mut after = seen("a", Checks::Green, "BLOCKED", false);
7450        after.rollup_head = "old".to_owned();
7451        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
7452    }
7453
7454    #[tokio::test]
7455    async fn a_matching_head_with_red_non_required_checks_still_merges() {
7456        let mut state = landing_state();
7457        let forge = Scripted::new(
7458            vec![seen("a", Checks::Red, "CLEAN", false)],
7459            vec![(true, "")],
7460        );
7461        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7462            .await
7463            .unwrap();
7464        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7465        assert_eq!(state.status, RunStatus::Merged);
7466    }
7467
7468    #[tokio::test]
7469    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
7470        let mut state = landing_state();
7471        let forge = Scripted::new(
7472            vec![
7473                seen("a", Checks::Green, "CLEAN", false),
7474                seen("a", Checks::Green, "CLEAN", false),
7475                // Re-viewed after the refusal: someone pushed.
7476                seen("b", Checks::Green, "BLOCKED", false),
7477                seen("b", Checks::Green, "CLEAN", false),
7478            ],
7479            vec![(false, REFUSED), (true, "")],
7480        );
7481        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
7482            .await
7483            .unwrap();
7484        assert_eq!(
7485            forge.calls(),
7486            [
7487                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
7488            ]
7489        );
7490        assert_eq!(state.status, RunStatus::Merged);
7491    }
7492
7493    fn merged_view(head: &str) -> Seen {
7494        let mut m = seen(head, Checks::Green, "CLEAN", false);
7495        m.pr.state = PrLifecycle::Merged;
7496        m
7497    }
7498
7499    fn has(argv: &[String], flag: &str) -> bool {
7500        argv.iter().any(|a| a == flag)
7501    }
7502
7503    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
7504        let at = argv.iter().position(|a| a == flag)?;
7505        argv.get(at + 1).map(String::as_str)
7506    }
7507
7508    const URL: &str = "https://github.com/o/r/pull/42";
7509
7510    #[tokio::test]
7511    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
7512        let mut state = landing_state();
7513        let forge = Scripted::new(
7514            vec![
7515                seen("abc", Checks::Green, "CLEAN", false),
7516                seen("abc", Checks::Green, "CLEAN", false),
7517            ],
7518            vec![(true, "")],
7519        );
7520        land_with(&mut state, URL, &forge).await.unwrap();
7521        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
7522        let argv = &forge.argvs()[0];
7523        assert!(has(argv, "--squash") && has(argv, "--subject"));
7524        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
7525        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
7526        assert_eq!(state.status, RunStatus::Merged);
7527        assert!(state.land_armed_head.is_none());
7528    }
7529
7530    #[tokio::test]
7531    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
7532        let mut state = landing_state();
7533        state.land_armed_head = Some("a".to_owned());
7534        let forge = Scripted::new(
7535            vec![seen("a", Checks::Green, "CLEAN", false)],
7536            vec![(true, ""), (true, "")],
7537        );
7538        land_with(&mut state, URL, &forge).await.unwrap();
7539        let argvs = forge.argvs();
7540        assert!(has(&argvs[0], "--disable-auto"));
7541        assert!(!has(&argvs[1], "--auto"));
7542        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
7543        assert_eq!(state.status, RunStatus::Merged);
7544        assert!(state.land_armed_head.is_none());
7545    }
7546
7547    #[tokio::test]
7548    async fn an_approval_never_carries_over_to_a_new_head() {
7549        crate::run::pin_test_home();
7550        let mut state = run_state();
7551        state.config.graph.land_approval = true;
7552        let pr = green_pr();
7553        let store = ask::Questions::open();
7554
7555        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
7556            .await
7557            .unwrap();
7558        let mut q = store
7559            .list()
7560            .into_iter()
7561            .find(|q| q.run == state.id)
7562            .unwrap();
7563        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
7564        store.put(&mut q).unwrap();
7565        assert_eq!(
7566            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
7567                .await
7568                .unwrap(),
7569            ApprovalGate::Approved,
7570            "the same head keeps its approval"
7571        );
7572
7573        // A new head is a new question, not the old word.
7574        assert_eq!(
7575            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7576                .await
7577                .unwrap(),
7578            ApprovalGate::Pending
7579        );
7580        let all: Vec<_> = store
7581            .list()
7582            .into_iter()
7583            .filter(|q| q.run == state.id)
7584            .collect();
7585        assert_eq!(all.len(), 2);
7586
7587        // A question recorded before heads were tracked is not reused either.
7588        state.land_approval = None;
7589        assert_eq!(
7590            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
7591                .await
7592                .unwrap(),
7593            ApprovalGate::Pending
7594        );
7595        let open = store
7596            .list()
7597            .into_iter()
7598            .filter(|q| q.run == state.id && q.status.open())
7599            .count();
7600        assert_eq!(open, 1, "the superseded question was retired");
7601    }
7602
7603    #[tokio::test]
7604    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
7605        let mut state = landing_state();
7606        let forge = Scripted::new(
7607            vec![
7608                seen("a", Checks::Green, "CLEAN", false),
7609                // The fresh read before the merge: someone pushed.
7610                seen("b", Checks::Green, "CLEAN", false),
7611            ],
7612            vec![(true, "")],
7613        );
7614        land_with(&mut state, URL, &forge).await.unwrap();
7615        let argvs = forge.argvs();
7616        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
7617        assert!(!has(&argvs[0], "--auto"));
7618        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
7619        assert_eq!(state.status, RunStatus::Merged);
7620    }
7621
7622    fn passing(label: &str) -> CheckInfo {
7623        CheckInfo {
7624            label: label.to_owned(),
7625            verdict: Verdict::Pass,
7626            required: Some(false),
7627            run: None,
7628        }
7629    }
7630
7631    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
7632        (label.to_owned(), run.map(str::to_owned))
7633    }
7634
7635    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
7636        LandReruns {
7637            head: head.to_owned(),
7638            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
7639        }
7640    }
7641
7642    #[test]
7643    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
7644        let f = [failing("test (macos)", Some("7"))];
7645        assert_eq!(
7646            rerun_step(&f, None, "h", 100),
7647            RerunStep::Rerun(vec!["7".to_owned()])
7648        );
7649        let rec = reran("h", &[("7", 100)]);
7650        let grace = crate::release_watch::RERUN_GRACE;
7651        assert_eq!(
7652            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
7653            RerunStep::Grace
7654        );
7655        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
7656    }
7657
7658    #[test]
7659    fn a_failure_without_a_run_id_goes_to_the_fixer() {
7660        let f = [failing("a", Some("7")), failing("ci/external", None)];
7661        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
7662        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
7663    }
7664
7665    #[test]
7666    fn a_moved_head_forgets_the_reruns() {
7667        let f = [failing("a", Some("7"))];
7668        let rec = reran("old", &[("7", 100)]);
7669        assert_eq!(
7670            rerun_step(&f, Some(&rec), "new", 101),
7671            RerunStep::Rerun(vec!["7".to_owned()])
7672        );
7673    }
7674
7675    #[test]
7676    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
7677        let f = [
7678            failing("a", Some("7")),
7679            failing("b", Some("7")),
7680            failing("c", Some("8")),
7681        ];
7682        assert_eq!(
7683            rerun_step(&f, None, "h", 0),
7684            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
7685        );
7686        let rec = reran("h", &[("7", 0)]);
7687        assert_eq!(
7688            rerun_step(&f, Some(&rec), "h", 1),
7689            RerunStep::Rerun(vec!["8".to_owned()])
7690        );
7691    }
7692
7693    #[test]
7694    fn only_blocking_failures_are_candidates_for_a_rerun() {
7695        let check = |label: &str, verdict, required, run: &str| CheckInfo {
7696            label: label.to_owned(),
7697            verdict,
7698            required,
7699            run: Some(run.to_owned()),
7700        };
7701        let got = rerunnable(&[
7702            check("passed", Verdict::Pass, Some(true), "1"),
7703            check("optional", Verdict::Fail, Some(false), "2"),
7704            check("required", Verdict::Fail, Some(true), "3"),
7705            check("unknown", Verdict::Fail, None, "4"),
7706        ]);
7707        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
7708        assert_eq!(labels, ["required", "unknown"]);
7709    }
7710
7711    fn red(head: &str, run: Option<&str>) -> Seen {
7712        let mut s = seen(head, Checks::Red, "BLOCKED", false);
7713        s.pr.failing = vec!["test (macos)".to_owned()];
7714        s.contexts = vec![CheckInfo {
7715            label: "test (macos)".to_owned(),
7716            verdict: Verdict::Fail,
7717            required: Some(true),
7718            run: run.map(str::to_owned),
7719        }];
7720        s
7721    }
7722
7723    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
7724
7725    #[tokio::test]
7726    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
7727        let mut state = landing_state();
7728        let forge = Scripted::new(
7729            vec![
7730                red("a", Some("7")),
7731                seen("a", Checks::Pending, "BLOCKED", false),
7732                seen("a", Checks::Green, "CLEAN", false),
7733            ],
7734            vec![(true, "")],
7735        );
7736        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7737        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7738        assert!(!forge.calls().contains(&"fix"));
7739        assert_eq!(state.status, RunStatus::Merged);
7740        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7741        let argvs = forge.argvs();
7742        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
7743        assert!(
7744            state
7745                .events
7746                .iter()
7747                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
7748        );
7749    }
7750
7751    #[tokio::test]
7752    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
7753        let mut state = landing_state();
7754        let forge = Scripted::new(
7755            vec![
7756                red("a", Some("7")),
7757                seen("a", Checks::Green, "CLEAN", false),
7758            ],
7759            vec![(true, "")],
7760        );
7761        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7762        // The red view repeats for the grace, then the fixer is asked once.
7763        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7764        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
7765        let calls = forge.calls();
7766        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
7767        let fix_at = calls.iter().position(|c| *c == "fix");
7768        assert!(fix_at.is_none_or(|f| f > rerun_at));
7769    }
7770
7771    #[tokio::test]
7772    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
7773        let mut state = landing_state();
7774        let mut v = red("a", Some("7"));
7775        v.pr.checks = Checks::Pending;
7776        v.contexts.push(CheckInfo {
7777            label: "slow".to_owned(),
7778            verdict: Verdict::Pending,
7779            required: Some(true),
7780            run: Some("8".to_owned()),
7781        });
7782        let forge = Scripted::new(
7783            vec![v, seen("a", Checks::Green, "CLEAN", false)],
7784            vec![(true, "")],
7785        );
7786        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7787        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7788        assert_eq!(state.status, RunStatus::Merged);
7789    }
7790
7791    #[test]
7792    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
7793        let mut v = red("a", Some("7"));
7794        v.contexts.push(CheckInfo {
7795            label: "same run, still going".to_owned(),
7796            verdict: Verdict::Pending,
7797            required: Some(true),
7798            run: Some("7".to_owned()),
7799        });
7800        assert!(rerunnable(&v.contexts).is_empty());
7801    }
7802
7803    #[tokio::test]
7804    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
7805        let mut state = landing_state();
7806        state.config.graph.land_rounds = 0;
7807        let forge = Scripted::new(
7808            vec![
7809                red("a", Some("7")),
7810                seen("a", Checks::Green, "CLEAN", false),
7811            ],
7812            vec![(true, "")],
7813        );
7814        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7815        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7816        assert_eq!(state.status, RunStatus::Merged);
7817        assert!(!forge.calls().contains(&"fix"));
7818        assert_eq!(state.pr.as_ref().unwrap().round, 0);
7819    }
7820
7821    #[tokio::test]
7822    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
7823        let mut state = landing_state();
7824        state.config.graph.land_rounds = 1;
7825        let forge = Scripted::new(
7826            vec![
7827                seen("a", Checks::Green, "CLEAN", true),
7828                red("b", Some("7")),
7829                seen("b", Checks::Green, "CLEAN", false),
7830            ],
7831            vec![(true, "")],
7832        );
7833        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
7834            head: "b".to_owned(),
7835        });
7836        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7837        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
7838        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
7839        assert_eq!(state.pr.as_ref().unwrap().round, 1);
7840        assert_eq!(state.status, RunStatus::Merged);
7841        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
7842        assert_eq!(
7843            value_of(&forge.argvs()[0], "--match-head-commit"),
7844            Some("b")
7845        );
7846    }
7847
7848    #[tokio::test]
7849    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
7850        let mut state = landing_state();
7851        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7852        *forge.rerun_fails.lock().unwrap() = true;
7853        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7854        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7855        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
7856        assert_eq!(
7857            forge.calls().iter().filter(|c| **c == "fix").count(),
7858            1,
7859            "{:?}",
7860            forge.calls()
7861        );
7862    }
7863
7864    #[tokio::test]
7865    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
7866        let mut state = landing_state();
7867        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7868        // The record was written just before the previous pass died.
7869        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
7870        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7871        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7872        assert!(forge.reruns.lock().unwrap().is_empty());
7873    }
7874
7875    #[tokio::test]
7876    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
7877        let mut state = landing_state();
7878        state.config.graph.rerun_flaky_checks = false;
7879        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
7880        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7881        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7882        assert!(forge.reruns.lock().unwrap().is_empty());
7883        assert!(forge.calls().contains(&"fix"));
7884
7885        let mut state = landing_state();
7886        let forge = Scripted::new(vec![red("a", None)], vec![]);
7887        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
7888        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
7889        assert!(forge.reruns.lock().unwrap().is_empty());
7890        assert!(forge.calls().contains(&"fix"));
7891    }
7892
7893    fn names(xs: &[&str]) -> BTreeSet<String> {
7894        xs.iter().map(|x| (*x).to_owned()).collect()
7895    }
7896
7897    #[test]
7898    fn a_required_check_the_rollup_never_listed_is_named() {
7899        let req = names(&["build"]);
7900        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
7901        assert!(why.contains("never reported: build"), "{why}");
7902        assert!(!why.contains("probably waiting for a review"), "{why}");
7903    }
7904
7905    #[test]
7906    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
7907        let why = waiting_on("BLOCKED", &[passing("review")], None);
7908        assert!(why.contains("could not be read"), "{why}");
7909        assert!(!why.contains("probably waiting for a review"), "{why}");
7910    }
7911
7912    #[test]
7913    fn all_required_reported_keeps_the_review_guess() {
7914        let req = names(&["build"]);
7915        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7916        assert!(why.contains("probably waiting for a review"), "{why}");
7917        assert!(!why.contains("never reported"), "{why}");
7918    }
7919
7920    #[test]
7921    fn required_names_match_the_rollup_ignoring_case_only() {
7922        let req = names(&["Build"]);
7923        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
7924        assert!(!why.contains("never reported"), "{why}");
7925    }
7926
7927    #[test]
7928    fn required_contexts_are_read_from_protection_and_rulesets() {
7929        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
7930        assert_eq!(
7931            parse_classic_required(classic),
7932            Some(names(&["build", "lint"]))
7933        );
7934        let rules = r#"[{"type":"pull_request","parameters":{}},
7935            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
7936        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
7937        assert_eq!(parse_ruleset_required("nope"), None);
7938        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
7939    }
7940
7941    #[test]
7942    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
7943        let shown = BTreeSet::new();
7944        let ok = seen("a", Checks::Green, "CLEAN", false);
7945        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
7946        assert!(guard(Some(&ok)));
7947        assert!(!guard(None));
7948        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
7949        let mut stale = ok.clone();
7950        stale.rollup_head = "old".to_owned();
7951        assert!(!guard(Some(&stale)));
7952        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
7953        assert!(!guard(Some(&merged_view("a"))));
7954    }
7955
7956    #[test]
7957    fn the_rollup_node_carries_whether_each_check_is_required() {
7958        let node = node_json("new", "SUCCESS", false)
7959            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
7960        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
7961        assert_eq!(s.contexts.len(), 1);
7962        assert_eq!(s.contexts[0].required, Some(true));
7963        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
7964        assert_eq!(s.contexts[0].required, None);
7965    }
7966
7967    #[tokio::test]
7968    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
7969        let mut state = landing_state();
7970        state.land_armed_head = Some("a".to_owned());
7971        let forge = Scripted::new(
7972            vec![seen("a", Checks::Green, "CLEAN", true)],
7973            vec![(false, "disable exploded")],
7974        );
7975        land_with(&mut state, URL, &forge).await.unwrap();
7976        assert!(!forge.calls().contains(&"fix"));
7977        assert_eq!(state.status, RunStatus::Blocked);
7978        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
7979        let why = state.merge.as_ref().unwrap().detail.clone();
7980        assert!(why.contains("disable exploded"), "{why}");
7981    }
7982}