Skip to main content

magi/
land.rs

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