Skip to main content

magi/
land.rs

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