Skip to main content

magi/
land.rs

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