Skip to main content

magi/
land.rs

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