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