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