Skip to main content

magi/
land.rs

1//! Landing the winner: watch the pull request, fix what it complains about,
2//! and merge it.
3//!
4//! Opening the pull request used to be where magi stopped and the operator
5//! started: watch the checks, read what the review bots found, push a fix,
6//! wait again, merge. That loop is mechanical, it takes an hour of wall-clock
7//! time per pull request, and doing it by hand six times in one session is how
8//! a queue that drains unattended stops being unattended. So it lives here.
9//!
10//! # Shape
11//!
12//! [`PrState`] is one observation of a pull request and [`decide`] is the whole
13//! policy as a *pure* function of it. Nothing in [`decide`] talks to `gh`,
14//! which is what makes "green with an unresolved comment is a fix, not a merge"
15//! an assertion in a test rather than a claim in a comment. [`land`] is the
16//! only part that performs I/O: observe, decide, act, repeat.
17//!
18//! # What it refuses to do
19//!
20//! Merging is the one irreversible thing magi can do to a repository, so the
21//! loop is built to stop rather than to guess:
22//!
23//! * A red pull request is never merged. When the budget runs out the pull
24//!   request is left open with a comment naming what is still failing, because
25//!   a magi that force-merges a red pull request is worse than one that stops.
26//! * A pull request whose checks cannot be read at all (`gh` reported no
27//!   rollup) is not merged either. Landing is for repositories with CI; with no
28//!   signal there is nothing to be green.
29//! * A pull request a human merged or closed underneath us is
30//!   [`Step::Done`] - the person won, and their decision is not an error.
31//!
32//! # Why `--subject` is not optional
33//!
34//! A candidate branch holds one commit whose subject is
35//! `magi: candidate A (uncommitted work)`, and `gh pr merge --squash` prefers a
36//! single commit's message over the pull request title. Merging without
37//! [`merge_argv`]'s explicit `--subject` therefore writes a `main` history that
38//! says nothing about what landed. `AGENTS.md` records the trap; this module is
39//! where it is prevented.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::fmt::Write as _;
43use std::path::{Path, PathBuf};
44use std::sync::Arc;
45use std::time::Duration;
46
47use anyhow::{Context as _, Result, bail};
48use jiff::Timestamp;
49use serde::{Deserialize, Serialize};
50
51use crate::agent::{self, Invocation, SeatState};
52use crate::ask;
53use crate::config::MergeMode;
54use crate::git;
55use crate::proc::Quiet as _;
56use crate::prompt;
57use crate::run::{
58    ContestedHandoff, LandApproval, LandReruns, MergeOutcome, RunState, RunStatus, tail,
59};
60
61/// How often the pull request is re-read while its checks are still running.
62///
63/// Thirty seconds: a CI matrix takes minutes, so anything shorter is spent
64/// entirely on `gh` invocations, and anything much longer adds latency to every
65/// single round of a loop that already waits for agents.
66pub const POLL: Duration = Duration::from_secs(30);
67
68/// How long one wait may last before landing gives up on the checks finishing.
69///
70/// A workflow that has not settled in forty-five minutes is stuck on a runner
71/// queue, a missing approval, or a hung job - none of which more polling fixes,
72/// and all of which a person needs to see.
73pub const WAIT_CEILING: Duration = Duration::from_secs(45 * 60);
74
75/// How long the checks may stay unreadable before landing gives up on them.
76///
77/// GitHub registers a workflow run some seconds after the branch is pushed, so
78/// immediately after a pull request is opened "no checks" and "no CI in this
79/// repository" look identical. Measured on run 01c2: magi opened pull request
80/// 22, read `unknown` four seconds later, refused to merge on a guess and
81/// marked the run blocked - and every check on that pull request was green
82/// minutes afterwards, with the whole competition then re-run from scratch for
83/// a task that was already finished. Three minutes is well past the observed
84/// registration delay and still bounded, so a repository that genuinely has no
85/// checks costs one three-minute wait and then says so.
86pub const CHECKS_GRACE: Duration = Duration::from_secs(3 * 60);
87
88/// Bytes of failing log kept per check. The fixer needs the assertion and the
89/// frame around it, not the forty thousand lines of `cargo` output above it.
90const LOG_TAIL: usize = 4_000;
91
92/// Failing checks whose logs are fetched. Beyond a handful the failures share a
93/// cause, and fetching each one costs a `gh` round trip.
94const MAX_LOGS: usize = 3;
95
96/// Marker carried by every comment magi posts on a pull request.
97///
98/// Without it magi's own "still failing" comment is indistinguishable from a
99/// reviewer's, and the next observation would hand magi's own prose to the
100/// fixer as a finding.
101pub const MARKER: &str = "<!-- magi:land -->";
102
103/// Markers a bot puts in a comment to say that the comment is not a review.
104///
105/// CodeRabbit labels its own machinery in HTML comments - the trigger notice,
106/// the walkthrough summary, the "thanks for using" footer - and its actual
107/// findings arrive as *inline* review comments with a path and a line. Taking
108/// the bot at its word is more honest than guessing from prose, and it is the
109/// difference between a fix round that has something to fix and one that asks
110/// an agent to act on a quota notice.
111const NOT_A_REVIEW: [&str; 3] = [
112    "skip review by coderabbit.ai",
113    "summarize by coderabbit.ai",
114    "<!-- tips_start -->",
115];
116
117/// Where a pull request is in its life.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum PrLifecycle {
120    /// Still ours to land.
121    Open,
122    /// Already merged, by us or by a person.
123    Merged,
124    /// Closed without merging.
125    Closed,
126}
127
128/// The aggregate verdict of a pull request's checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Checks {
131    /// At least one check has not finished.
132    Pending,
133    /// Every check passed (a skipped check counts as passed: the review
134    /// workflow skips release and bot pull requests by design).
135    Green,
136    /// At least one check finished without passing.
137    Red,
138    /// Nothing readable - no rollup at all, or a status magi does not know.
139    Unknown,
140}
141
142impl PrLifecycle {
143    /// Stable lower-case name, as the API and the reports spell it.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Open => "open",
147            Self::Merged => "merged",
148            Self::Closed => "closed",
149        }
150    }
151}
152
153impl Checks {
154    /// Stable lower-case name, as the API and the reports spell it.
155    pub fn as_str(self) -> &'static str {
156        match self {
157            Self::Pending => "pending",
158            Self::Green => "green",
159            Self::Red => "red",
160            Self::Unknown => "unknown",
161        }
162    }
163}
164
165/// One outstanding review comment, human or bot.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ReviewComment {
168    /// Login of whoever wrote it.
169    pub author: String,
170    /// File it was left on, for inline review comments.
171    pub path: Option<String>,
172    /// Line it was left on, when the comment is inline and still anchored.
173    pub line: Option<u64>,
174    /// The comment itself, as written.
175    pub body: String,
176}
177
178/// One observation of a pull request.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct PrState {
181    /// Pull request url, as `gh` reports it.
182    pub url: String,
183    /// Pull request number.
184    pub number: u64,
185    /// Open, merged, or closed.
186    pub state: PrLifecycle,
187    /// Aggregate check verdict.
188    pub checks: Checks,
189    /// Names of the checks that finished without passing.
190    pub failing: Vec<String>,
191    /// Comments that still want an answer, human and bot.
192    pub review_comments: Vec<ReviewComment>,
193    /// Whether the forge itself considers the failures blocking.
194    pub blocking: Blocking,
195}
196
197/// Whether a failing check actually stands between the pull request and
198/// `main`, according to the forge.
199///
200/// The rollup lists every check equally, so `coverage` going red on a
201/// repository that deliberately does not require it looked exactly like a
202/// broken build - and magi answered by spending a fix round on a change that
203/// was fine. Pull request 37 had to be merged by hand for that reason: the
204/// only red check was `editorconfig`, which was failing because the *action*
205/// could not fetch its own binary, and which the repository does not require.
206///
207/// `mergeStateStatus` is where GitHub applies the required-check set, so it
208/// is the one field that can tell the difference.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Blocking {
211    /// Required checks are satisfied and the branch merges cleanly.
212    No,
213    /// Something required is failing or missing.
214    Yes,
215    /// The branch no longer merges: the base moved under it.
216    Conflict,
217    /// The forge did not say - an older `gh`, or a token without the scope.
218    /// Treated as `Yes`, because refusing to guess is the rule everywhere
219    /// else in this module.
220    Unsaid,
221}
222
223impl Blocking {
224    /// Read `mergeStateStatus`, which is upper-case in `gh`'s output.
225    fn of(raw: &str) -> Self {
226        match raw.to_ascii_uppercase().as_str() {
227            // Mergeable. `UNSTABLE` is the interesting one: mergeable, with a
228            // non-required check failing or still running.
229            "CLEAN" | "UNSTABLE" | "HAS_HOOKS" => Self::No,
230            "DIRTY" => Self::Conflict,
231            "" | "UNKNOWN" => Self::Unsaid,
232            // BLOCKED, BEHIND, DRAFT: something has to change first.
233            _ => Self::Yes,
234        }
235    }
236
237    /// Does this stand between the pull request and the base branch?
238    #[must_use]
239    pub fn stops_a_merge(self) -> bool {
240        !matches!(self, Self::No)
241    }
242}
243
244/// What the loop decided to do next. Pure, so the policy is testable.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum Step {
247    /// Checks are still running; re-read the pull request after [`POLL`].
248    Wait,
249    /// The base moved and the branch no longer merges: rebase it.
250    ///
251    /// Not a fix round. Nothing is wrong with the change - a competition
252    /// that runs for two hours against a repository merging pull requests
253    /// all day conflicts on the way in, and that is arithmetic rather than a
254    /// defect. Pull requests 35 and 37 were both rebased by hand for exactly
255    /// this.
256    Rebase,
257    /// Red checks or unresolved comments; run a fix round.
258    Fix {
259        /// What is unhappy, in one line, for the run log and the fix prompt.
260        reason: String,
261    },
262    /// Green and nothing outstanding; merge it.
263    Merge,
264    /// The pull request left our hands.
265    Done {
266        /// Did it land, or was it closed?
267        merged: bool,
268    },
269    /// Stop and leave the pull request to a person.
270    GiveUp {
271        /// Why magi stopped, in one line.
272        reason: String,
273    },
274}
275
276/// The outcome to record when `gh pr merge` exits non-zero, given what the
277/// pull request looked like immediately afterwards.
278///
279/// `gh pr merge` merges server-side first and only then does local work -
280/// deleting the branch, switching back to a base branch - so a non-zero exit
281/// does not mean the merge did not happen. In a jj-colocated repository it
282/// reliably does not mean that: git HEAD is detached, and `--delete-branch`
283/// ends with "could not determine current branch: not on any branch" *after*
284/// the merge has landed. Run ec12 merged pull request 28 into `main` and
285/// recorded `ok: false`, and its task was held waiting for a merge that was
286/// already done.
287///
288/// So the forge is asked, and its answer wins - the same authority [`decide`]
289/// gives the pull request's own state over everything else. The recorded
290/// detail carries both facts, because "the command failed and the merge
291/// happened anyway" is exactly what someone reading the run later needs to
292/// know.
293///
294/// `None` means the merge really did not happen, including when the pull
295/// request could not be read at all: an unreadable answer is not evidence of
296/// success.
297pub(crate) fn merged_after_all(
298    argv: &[String],
299    stderr: &str,
300    after: Option<PrLifecycle>,
301) -> Option<MergeOutcome> {
302    if after? != PrLifecycle::Merged {
303        return None;
304    }
305    Some(MergeOutcome {
306        mode: MergeMode::Pr,
307        ok: true,
308        detail: format!(
309            "gh {} (the command reported `{}`, but the pull request is merged)",
310            argv.join(" "),
311            stderr.trim()
312        ),
313        empty: false,
314    })
315}
316
317/// Decide the next step. No I/O.
318///
319/// `round` counts the fix rounds already spent, so `round == budget` means the
320/// budget is gone. A wait never spends a round: waiting is free, and a slow CI
321/// must not consume the allowance meant for actual fixes.
322///
323/// The order of the tests is the policy:
324///
325/// 1. **The pull request's own state wins.** A merge or a close that happened
326///    underneath us is the end of the story regardless of what the checks say.
327/// 2. **Pending beats red.** A check that is still running may yet fail, and one
328///    fix round that addresses every failure is cheaper than two that each
329///    address half - the fix pushes and restarts the whole suite anyway.
330/// 3. **Comments outrank green.** An unresolved comment holds the merge even
331///    when CI is happy; that is what a review is for.
332/// 4. **Unreadable is not absent.** Checks that cannot be read yet are waited
333///    on for [`CHECKS_GRACE`], because a pull request opened a moment ago has
334///    not been given its workflow runs yet. Past the grace they are treated as
335///    genuinely missing and magi stops rather than merge on a guess.
336pub fn decide(pr: &PrState, round: usize, budget: usize, waited: Duration) -> Step {
337    decide_with(pr, round, budget, waited, CiExpectation::Expected)
338}
339
340/// Whether the repository's CI is expected to report on this pull request.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum CiExpectation {
343    /// Checks will come: wait for them, judge them (the default, and what
344    /// [`decide`] always uses).
345    Expected,
346    /// No check will ever report (`[release] mode = "local"` on a repository
347    /// whose Actions never run). Waiting out [`CHECKS_GRACE`] or reading
348    /// `unknown` as a refusal would stall forever, so the checks are read as
349    /// absent and the pull request is ready as soon as it merges cleanly.
350    Absent,
351}
352
353/// [`decide`] with the CI expectation made explicit. `Expected` is exactly
354/// [`decide`]; `Absent` reads the absence of CI as the reason to proceed, and
355/// still stops for a conflict (the base moved), which a rebase cures and no
356/// check state does.
357pub fn decide_with(
358    pr: &PrState,
359    round: usize,
360    budget: usize,
361    waited: Duration,
362    ci: CiExpectation,
363) -> Step {
364    match pr.state {
365        PrLifecycle::Merged => return Step::Done { merged: true },
366        PrLifecycle::Closed => return Step::Done { merged: false },
367        PrLifecycle::Open => {}
368    }
369
370    // Before the checks: every check on a branch that cannot land is an
371    // answer about a state that cannot land.
372    if pr.blocking == Blocking::Conflict {
373        return Step::Rebase;
374    }
375
376    if ci == CiExpectation::Absent {
377        return Step::Merge;
378    }
379
380    let spent = round >= budget;
381    match pr.checks {
382        Checks::Pending => Step::Wait,
383        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
384        Checks::Unknown => Step::GiveUp {
385            reason: format!(
386                "no check status is readable on the pull request after {} minute(s); \
387                 refusing to merge on a guess",
388                CHECKS_GRACE.as_secs() / 60
389            ),
390        },
391        // Red, but the forge says it does not stand in the way: the failing
392        // checks are ones this repository chose not to require. Spending a fix
393        // round on them asks an agent to repair something nobody is gating on
394        // - and pull request 37's only red check was an *action* that could
395        // not fetch its own binary. Merge, and name them so the record is
396        // honest about what was red when it landed.
397        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
398        Checks::Red => {
399            let what = format!(
400                "{} check(s) failing: {}",
401                pr.failing.len(),
402                pr.failing.join(", ")
403            );
404            if spent {
405                Step::GiveUp {
406                    reason: format!("{what} — still red after {budget} fix round(s)"),
407                }
408            } else {
409                Step::Fix { reason: what }
410            }
411        }
412        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
413        Checks::Green => {
414            let what = format!(
415                "checks are green but {} review comment(s) are unresolved: {}",
416                pr.review_comments.len(),
417                authors(&pr.review_comments)
418            );
419            if spent {
420                Step::GiveUp {
421                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
422                }
423            } else {
424                Step::Fix { reason: what }
425            }
426        }
427    }
428}
429
430/// Distinct comment authors, in the order they first appear.
431fn authors(comments: &[ReviewComment]) -> String {
432    let mut seen: Vec<&str> = Vec::new();
433    for c in comments {
434        if !seen.contains(&c.author.as_str()) {
435            seen.push(&c.author);
436        }
437    }
438    seen.join(", ")
439}
440
441/// The argv magi merges with, minus the program name.
442///
443/// `--subject` is the point of this function existing: see the module docs.
444pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
445    vec![
446        "pr".to_owned(),
447        "merge".to_owned(),
448        number.to_string(),
449        "--squash".to_owned(),
450        "--delete-branch".to_owned(),
451        "--subject".to_owned(),
452        subject.to_owned(),
453    ]
454}
455
456/// [`merge_argv`] pinned to the commit the decision was made about.
457///
458/// `--match-head-commit` makes the forge refuse the merge if the branch moved
459/// after it was observed, so a push landing between the look and the merge is
460/// never merged unseen.
461pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
462    let mut argv = merge_argv(number, subject);
463    argv.push("--match-head-commit".to_owned());
464    argv.push(head.to_owned());
465    argv
466}
467
468/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
469/// an older build may still have one standing on the forge, so the disarm
470/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
471/// armed merge never outlives the commit that was approved for it.
472pub fn disable_automerge_argv(number: u64) -> Vec<String> {
473    ["pr", "merge", &number.to_string(), "--disable-auto"]
474        .map(str::to_owned)
475        .to_vec()
476}
477
478/// May the direct merge go ahead, judged from a fresh read?
479///
480/// The pull request must still be open on the approved head, the checks must
481/// have been read for that very head, and the policy must still say merge.
482/// Pure, so the head guard is asserted without a forge.
483fn direct_merge_is_safe(
484    fresh: Option<&Seen>,
485    approved_head: &str,
486    shown: &BTreeSet<String>,
487    round: usize,
488    budget: usize,
489    waited: Duration,
490) -> bool {
491    let Some(fresh) = fresh else {
492        return false;
493    };
494    if fresh.pr.state != PrLifecycle::Open {
495        return false;
496    }
497    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
498        return false;
499    };
500    if !bound.eq_ignore_ascii_case(approved_head) {
501        return false;
502    }
503    let mut pr = fresh.pr.clone();
504    pr.review_comments.retain(|c| !shown.contains(&c.body));
505    decide(&pr, round, budget, waited) == Step::Merge
506}
507
508/// What GitHub is still waiting for, for the stop reason when an armed merge
509/// does not happen in time. No I/O.
510///
511/// Required checks that are pending or failing are named. A check whose
512/// required-ness could not be read is never assumed optional: every unsettled
513/// check is listed and the reason says so.
514fn waiting_on(
515    merge_state: &str,
516    contexts: &[CheckInfo],
517    required_set: Option<&BTreeSet<String>>,
518) -> String {
519    let state = if merge_state.is_empty() {
520        "unknown"
521    } else {
522        merge_state
523    };
524    let tag = |c: &CheckInfo| match c.verdict {
525        Verdict::Fail => "failed",
526        _ => "pending",
527    };
528    let unsettled: Vec<&CheckInfo> = contexts
529        .iter()
530        .filter(|c| c.verdict != Verdict::Pass)
531        .collect();
532    let required: Vec<String> = unsettled
533        .iter()
534        .filter(|c| c.required == Some(true))
535        .map(|c| format!("{} ({})", c.label, tag(c)))
536        .collect();
537    let unknown: Vec<String> = unsettled
538        .iter()
539        .filter(|c| c.required.is_none())
540        .map(|c| format!("{} ({})", c.label, tag(c)))
541        .collect();
542    // Required contexts the rollup never listed: nothing reported them, so no
543    // pending/failing entry exists to name. Matched case-insensitively against
544    // the labels as the rollup spells them, and no further.
545    let never: Vec<&str> = required_set
546        .map(|set| {
547            set.iter()
548                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
549                .map(String::as_str)
550                .collect()
551        })
552        .unwrap_or_default();
553    let mut out = format!("merge state: {state}");
554    if !never.is_empty() {
555        let _ = write!(
556            out,
557            "; required checks never reported: {}",
558            never.join(", ")
559        );
560    }
561    if !required.is_empty() {
562        let _ = write!(
563            out,
564            "; required checks not passing: {}",
565            required.join(", ")
566        );
567    }
568    if !unknown.is_empty() {
569        let _ = write!(
570            out,
571            "; whether these are required could not be read, so they may be: {}",
572            unknown.join(", ")
573        );
574    }
575    if required.is_empty() && unknown.is_empty() && never.is_empty() {
576        if required_set.is_some() {
577            out.push_str(
578                "; no required check is pending, failing or unreported, so GitHub is probably \
579                 waiting for a review or another branch rule",
580            );
581        } else {
582            out.push_str(
583                "; the required check list could not be read, so a required check that was \
584                 never reported cannot be ruled out",
585            );
586        }
587    }
588    out
589}
590
591/// The squash subject to merge under.
592///
593/// The pull request title, unless it is empty or is a candidate branch's commit
594/// subject that leaked into the title - in which case the task's own first line
595/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
596/// reader nothing about what landed.
597pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
598    let title = pr_title.trim();
599    if !title.is_empty() && !title.starts_with("magi: candidate") {
600        return title.to_owned();
601    }
602    let first = instruction
603        .lines()
604        .map(str::trim)
605        .find(|l| !l.is_empty())
606        .unwrap_or("magi: land the winning candidate");
607    first.trim_start_matches(['#', ' ']).to_owned()
608}
609
610/// The choice that lets the merge happen, verbatim as the owner taps it.
611pub const APPROVE: &str = "merge";
612
613/// The choice that leaves the pull request open.
614pub const HOLD: &str = "hold";
615
616/// A bounded repair followed by a new review and a fresh owner decision.
617pub const REPAIR: &str = "fix findings and re-review";
618
619/// Whether review evidence offers a meaningful repair choice. This does not
620/// decide whether owner approval is required.
621pub fn repair_available(state: &RunState) -> bool {
622    state.reviews.last().is_some_and(|r| {
623        state.open_findings().iter().any(|f| f.severity.blocks())
624            || r.final_votes()
625                .iter()
626                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
627    })
628}
629
630/// All applicable reasons for consulting the owner, independent of repair choices.
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum ApprovalReason {
633    /// Universal owner approval is enabled for every merge.
634    EveryMerge,
635    /// Contested-merge approval is enabled and blocking findings remain open.
636    OpenBlockingFindings,
637    /// Contested-merge approval is enabled and a final reviewer vote rejects.
638    FinalReject,
639    /// An existing owner decision must be honored even if controls change.
640    ExistingDecision,
641    /// Merge repair requires a fresh owner decision, even on the same head.
642    AfterRepair,
643}
644
645/// Disabling fresh approval controls never bypasses an existing owner decision
646/// or the fresh decision owed after repair, even if repair kept the same head.
647pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
648    let mut reasons = Vec::new();
649    if state.config.graph.land_approval {
650        reasons.push(ApprovalReason::EveryMerge);
651    }
652    if state.config.graph.hold_contested_merge {
653        if state.open_findings().iter().any(|f| f.severity.blocks()) {
654            reasons.push(ApprovalReason::OpenBlockingFindings);
655        }
656        if state.reviews.last().is_some_and(|r| {
657            r.final_votes()
658                .iter()
659                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
660        }) {
661            reasons.push(ApprovalReason::FinalReject);
662        }
663    }
664    if state.land_approval.is_some() {
665        reasons.push(ApprovalReason::ExistingDecision);
666    }
667    if !state.merge_repairs.is_empty() {
668        reasons.push(ApprovalReason::AfterRepair);
669    }
670    reasons
671}
672
673/// Bound free-form approval evidence by characters, including an explicit
674/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
675fn approval_excerpt(text: &str, max_chars: usize) -> String {
676    if text.chars().count() <= max_chars {
677        text.to_owned()
678    } else {
679        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
680        excerpt.push('…');
681        excerpt
682    }
683}
684
685/// Persisted verbatim in the question and panel at creation. Deputies and
686/// resumed questions use that snapshot, never reconstruct past policy from
687/// today's settings. Fixer claims and successful gates are not resolutions.
688fn approval_evidence(state: &RunState) -> String {
689    const TITLE_CHARS: usize = 100;
690    const REASON_CHARS: usize = 300;
691    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
692    let ja = words(&state.config.graph.language).html_lang == "ja";
693    let mut s = if ja {
694        "承認が必要な理由:\n"
695    } else {
696        "Approval required because:\n"
697    }
698    .to_owned();
699    for reason in approval_reasons(state) {
700        let label = match (reason, ja) {
701            (ApprovalReason::EveryMerge, false) => {
702                "land_approval=true: owner approval for every merge"
703            }
704            (ApprovalReason::EveryMerge, true) => {
705                "land_approval=true: すべてのマージに所有者の承認が必要"
706            }
707            (ApprovalReason::OpenBlockingFindings, false) => {
708                "hold_contested_merge=true: open Major-or-above internal findings"
709            }
710            (ApprovalReason::OpenBlockingFindings, true) => {
711                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
712            }
713            (ApprovalReason::FinalReject, false) => {
714                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
715            }
716            (ApprovalReason::FinalReject, true) => {
717                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
718            }
719            (ApprovalReason::ExistingDecision, false) => {
720                "existing owner decision: changing controls cannot bypass it"
721            }
722            (ApprovalReason::ExistingDecision, true) => {
723                "既存の所有者判断: 設定変更では迂回できません"
724            }
725            (ApprovalReason::AfterRepair, false) => {
726                "fresh owner approval after merge repair, even with an unchanged head"
727            }
728            (ApprovalReason::AfterRepair, true) => {
729                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
730            }
731        };
732        let _ = writeln!(s, "- {label}");
733    }
734    s.push_str(if ja {
735        "\n再考後の各レビュアーの最終票:\n"
736    } else {
737        "\nFinal reviewer votes after reconsideration:\n"
738    });
739    if let Some(round) = state.reviews.last() {
740        let votes = round.final_votes();
741        for r in &round.reviews {
742            let vote = votes
743                .iter()
744                .find(|(seat, _, _)| *seat == r.reviewer)
745                .map(|(_, _, v)| v.label())
746                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
747            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
748            let rv = round
749                .reconsideration
750                .iter()
751                .find(|rv| rv.reviewer == r.reviewer);
752            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
753                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
754                let _ = writeln!(
755                    s,
756                    "  {}: {initial} → {vote}",
757                    if ja { "再考" } else { "reconsideration" }
758                );
759                let _ = writeln!(
760                    s,
761                    "  {}: {}",
762                    if ja { "理由" } else { "reason" },
763                    reason(if rv.reason.trim().is_empty() {
764                        if ja { "記録なし" } else { "not recorded" }
765                    } else {
766                        &rv.reason
767                    })
768                );
769            } else {
770                let _ = writeln!(
771                    s,
772                    "  {}: {}",
773                    if ja {
774                        "初回票を保持・理由"
775                    } else {
776                        "initial vote retained; reason"
777                    },
778                    reason(if r.summary.trim().is_empty() {
779                        if ja { "記録なし" } else { "not recorded" }
780                    } else {
781                        &r.summary
782                    })
783                );
784                if let Some(rv) = rv {
785                    let _ = writeln!(
786                        s,
787                        "  {}: {}",
788                        if ja {
789                            "再投票の回答なし"
790                        } else {
791                            "no reconsideration vote"
792                        },
793                        reason(rv.failed.as_deref().unwrap_or(if ja {
794                            "理由の記録なし"
795                        } else {
796                            "reason not recorded"
797                        }))
798                    );
799                }
800            }
801            if let Some(failed) = &r.failed {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja { "回答なし" } else { "no answer" },
806                    reason(failed)
807                );
808            }
809        }
810    } else {
811        s.push_str(if ja {
812            "レビュー記録なし\n"
813        } else {
814            "No review recorded\n"
815        });
816    }
817    s.push_str(if ja { "\n内部レビュー指摘(最終ラウンド): 採用申告や gate 成功だけでは個別指摘の解決確認にはなりません。\n" } else { "\nInternal review findings (last round): adoption claims or a passing gate alone do not confirm individual resolution.\n" });
818    let findings = state.last_round_findings();
819    for f in &findings {
820        let _ = writeln!(
821            s,
822            "[{}] {} {}:{}: {}\n{}",
823            f.id,
824            f.severity,
825            f.file.as_deref().unwrap_or("?"),
826            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
827            approval_excerpt(&f.title, TITLE_CHARS),
828            reason(&f.detail)
829        );
830        let mut evidence = false;
831        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
832            state
833                .operator_fixes
834                .iter()
835                .filter_map(|request| request.fix.as_ref()),
836        ) {
837            if fix.addressed.contains(&f.id) {
838                evidence = true;
839                let _ = writeln!(
840                    s,
841                    "  {} ({}): {}",
842                    if ja {
843                        "採用申告(解決未確認)"
844                    } else {
845                        "claimed addressed (resolution unconfirmed)"
846                    },
847                    fix.agent,
848                    reason(&fix.notes)
849                );
850            }
851            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
852                evidence = true;
853                let _ = writeln!(
854                    s,
855                    "  {}: {}",
856                    if ja { "不採用理由" } else { "declined" },
857                    reason(&rejection.why)
858                );
859            }
860        }
861        if !evidence {
862            s.push_str(if ja {
863                "  採用・解決確認の記録なし\n"
864            } else {
865                "  No adoption or resolution evidence recorded\n"
866            });
867        }
868    }
869    if findings.is_empty() {
870        s.push_str(if ja {
871            "最終ラウンドに指摘の記録なし\n"
872        } else {
873            "No findings recorded in the last round\n"
874        });
875    }
876    s
877}
878
879/// Shared choices for web, CLI and consultation questions.
880pub fn approval_choices(state: &RunState) -> Vec<String> {
881    let mut choices = vec![
882        APPROVE.to_owned(),
883        HOLD.to_owned(),
884        REPAIR.to_owned(),
885        crate::merge_decision::WITHDRAW.to_owned(),
886        crate::merge_decision::DISMISS.to_owned(),
887    ];
888    choices.extend(
889        state
890            .config
891            .agents
892            .iter()
893            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
894    );
895    choices
896}
897
898/// Extend unanswered legacy cards in place. Answered cards keep their choices;
899/// a legacy repair answer gains only its missing dispatch action.
900pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
901    if q.node != APPROVAL_NODE
902        || !state
903            .land_approval
904            .as_ref()
905            .is_some_and(|a| a.question == q.id)
906    {
907        return false;
908    }
909    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
910    if q.status.open() {
911        // Older cards attached Resume to hold; a hold must remain answerable
912        // without dispatching a question action.
913        q.actions.remove(HOLD);
914        for choice in approval_choices(state) {
915            if !q.choices.contains(&choice) {
916                q.choices.push(choice);
917            }
918        }
919        if q.merge_target.is_none() {
920            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
921                pr: pr.url.clone(),
922                head: state.land_approval.as_ref().unwrap().head.clone(),
923                language: state.config.graph.language.clone(),
924            });
925        }
926    }
927    for choice in q.choices.clone() {
928        if (q.status.open()
929            && crate::merge_decision::stops(&choice)
930            && choice != HOLD
931            && choice != crate::merge_decision::DISMISS)
932            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
933        {
934            q.actions.insert(
935                choice,
936                ask::ChoiceAction::Resume {
937                    run: state.id.clone(),
938                },
939            );
940        }
941    }
942    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
943}
944
945/// Whether `quote` is a verbatim part of the owner's `message` and nothing in
946/// the whole message carries a hedge, a condition, a negation, a question or a
947/// retraction. Used by `deputy::destructive`.
948///
949/// The whole message is read, not just the quote's sentence: a condition or a
950/// second thought in another sentence ("Merge it. Only if CI passes.") makes
951/// the approval conditional all the same. Doubt anywhere is `false`.
952pub fn unhedged(message: &str, quote: &str) -> bool {
953    let quote = quote.trim();
954    !quote.is_empty() && message.contains(quote) && !hedged(message) && !retracts(message)
955}
956
957/// Hedging, conditional, negated or interrogative wording. ASCII words are
958/// matched as whole words so `note` is not `not`. A bare negation or stop word
959/// (`no`, `stop`, `nope`, ...) anywhere in the message voids the approval.
960fn hedged(text: &str) -> bool {
961    const WORDS: &[&str] = &[
962        "maybe",
963        "probably",
964        "perhaps",
965        "might",
966        "if",
967        "unless",
968        "not",
969        "no",
970        "nope",
971        "stop",
972        "dont",
973        "abort",
974        "revert",
975        "undo",
976        "never",
977        "wait",
978        "hold",
979        "cancel",
980        "but",
981        "think",
982        "guess",
983        "suppose",
984        "unsure",
985        "yet",
986        "except",
987        "only",
988        "cannot",
989        "should",
990        "once",
991        "provided",
992        "providing",
993        "when",
994        "whenever",
995        "after",
996        "until",
997        "before",
998        "assuming",
999        "given",
1000        "while",
1001        "whether",
1002        "depending",
1003        "pending",
1004    ];
1005    const JA: &[&str] = &[
1006        "かも",
1007        "たぶん",
1008        "多分",
1009        "なら",
1010        "たら",
1011        "ちょっと待",
1012        "しないで",
1013        "しない",
1014        "保留",
1015        "まだ",
1016        "ただし",
1017        "やめ",
1018        "いや",
1019        "止め",
1020        "だめ",
1021        "ダメ",
1022        "じゃない",
1023        "ではない",
1024        "ですか",
1025        "かな",
1026        "でしょう",
1027        "思う",
1028        "でも",
1029        "けど",
1030        "ただ",
1031        "次第",
1032        "場合",
1033        "限り",
1034        "条件",
1035        "とき",
1036        "まで",
1037        "後で",
1038    ];
1039    if text.contains(['?', '?']) || JA.iter().any(|w| text.contains(w)) {
1040        return true;
1041    }
1042    text.to_lowercase()
1043        .replace('\u{2019}', "'")
1044        .split(|c: char| !(c.is_alphanumeric() || c == '\'') || !c.is_ascii())
1045        .filter(|w| !w.is_empty())
1046        .any(|w| WORDS.contains(&w) || w.ends_with("n't"))
1047}
1048
1049/// Wording that takes back what the rest of the message said. Bare negation
1050/// and stop words are `hedged`'s whole-word list, not substrings here.
1051fn retracts(message: &str) -> bool {
1052    let lower = message.to_lowercase();
1053    [
1054        "やっぱ",
1055        "待って",
1056        "撤回",
1057        "never mind",
1058        "actually",
1059        "on second thought",
1060    ]
1061    .iter()
1062    .any(|w| lower.contains(w))
1063        || lower
1064            .split(|c: char| !c.is_ascii_alphabetic())
1065            .any(|w| w == "wait")
1066}
1067
1068/// Graph node recorded on the approval question.
1069///
1070/// The phone keys its high-stakes card off this rather than off the choice
1071/// strings, so renaming a button cannot silently downgrade the card that
1072/// guards the one irreversible action magi takes.
1073pub const APPROVAL_NODE: &str = "land-approval";
1074
1075/// Unified diff lines carried in the panel before it is truncated.
1076///
1077/// Four hundred: the panel is read on a 390px phone, where a diff line often
1078/// wraps to two rows, so this is already a few thousand rows of scrolling -
1079/// past that nobody is reading, and the bytes still count against the panel's
1080/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
1081/// cut and which worktree holds the whole patch.
1082pub const DIFF_MAX_LINES: usize = 400;
1083
1084/// What the owner's answer to the approval question means.
1085#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum Approval {
1087    /// The owner said [`APPROVE`]. Merge.
1088    Merge,
1089    /// Repair the existing PR, then ask again; never approval to merge.
1090    Repair,
1091    /// Anything else, including silence. Leave the pull request open.
1092    Hold,
1093}
1094
1095/// Read the owner's answer, where `None` is an unanswered question.
1096///
1097/// Silence is a hold. A timed-out question means the owner never saw it or
1098/// never decided, and defaulting an irreversible merge to "yes" would make this
1099/// gate worse than no gate at all: it would merge unattended while claiming to
1100/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1101/// function does not recognise holds too.
1102pub fn approval(answer: Option<&str>) -> Approval {
1103    match answer {
1104        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1105        Some(a) if a.trim() == REPAIR => Approval::Repair,
1106        _ => Approval::Hold,
1107    }
1108}
1109
1110/// What [`approval_gate`] found on one check of the owner's merge decision.
1111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112enum ApprovalGate {
1113    /// The owner said [`APPROVE`]. Merge.
1114    Approved,
1115    /// The owner explicitly requested repair, without authorizing a merge.
1116    Repair,
1117    /// The owner said anything else, the question timed out, or it was
1118    /// closed with no decision recorded.
1119    Held,
1120    /// Filed and still waiting - the caller parks rather than blocking on it.
1121    Pending,
1122}
1123
1124/// Escape text for HTML, including both quote characters.
1125///
1126/// Every string in the panel is agent-influenced: a branch name, a file path, a
1127/// commit subject, a review comment. The sandboxed frame stops such text from
1128/// *running*, but it does not stop a `<` from ending the document early or a
1129/// `"` from ending an attribute and inventing a new one - the panel would then
1130/// render a lie, or not render at all. Both quotes are escaped because the same
1131/// function is used inside attributes, where remembering which quote style the
1132/// caller used is one mistake away from an injected attribute.
1133fn esc(s: &str) -> String {
1134    let mut out = String::with_capacity(s.len());
1135    for c in s.chars() {
1136        match c {
1137            '&' => out.push_str("&amp;"),
1138            '<' => out.push_str("&lt;"),
1139            '>' => out.push_str("&gt;"),
1140            '"' => out.push_str("&quot;"),
1141            '\'' => out.push_str("&#39;"),
1142            _ => out.push(c),
1143        }
1144    }
1145    out
1146}
1147
1148/// One row of the diffstat table.
1149#[derive(Debug, Clone, PartialEq, Eq)]
1150struct StatRow {
1151    path: String,
1152    /// `None` for a binary file, which `git` reports as `-`.
1153    added: Option<u64>,
1154    removed: Option<u64>,
1155}
1156
1157impl StatRow {
1158    /// Lines touched, for sorting. A binary file counts as zero rather than as
1159    /// unknown, which puts it at the bottom where it needs no attention.
1160    fn churn(&self) -> u64 {
1161        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1162    }
1163}
1164
1165/// Parse `git diff --numstat` into rows, biggest churn first.
1166///
1167/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1168/// terminal width, so counting its characters would print fabricated numbers in
1169/// the one table an operator approves an irreversible action from.
1170fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1171    let mut rows: Vec<StatRow> = numstat
1172        .lines()
1173        .filter_map(|line| {
1174            let mut parts = line.splitn(3, '\t');
1175            let added = parts.next()?.trim();
1176            let removed = parts.next()?.trim();
1177            let path = parts.next()?.trim();
1178            if path.is_empty() {
1179                return None;
1180            }
1181            Some(StatRow {
1182                path: path.to_owned(),
1183                added: added.parse().ok(),
1184                removed: removed.parse().ok(),
1185            })
1186        })
1187        .collect();
1188    // Path breaks the tie so the same change always renders the same table; an
1189    // operator comparing two panels should not see rows shuffle.
1190    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1191    rows
1192}
1193
1194/// How one diff line is shown: a gutter character, a style, and the body to
1195/// print - which is the line minus its marker, so the marker appears exactly
1196/// once, in the gutter.
1197///
1198/// The gutter is why this exists at all. The operator may be colour blind, or
1199/// reading in sunlight with the screen dimmed, so an added line is never
1200/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1201/// the same mark they already read in a terminal.
1202fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1203    if line.starts_with("+++") || line.starts_with("---") {
1204        (" ", "color:#57606a;font-weight:600", line)
1205    } else if let Some(body) = line.strip_prefix('+') {
1206        ("+", "background:#e6ffec;color:#0a3622", body)
1207    } else if let Some(body) = line.strip_prefix('-') {
1208        ("-", "background:#ffebe9;color:#5c1a17", body)
1209    } else if line.starts_with("@@") {
1210        ("~", "background:#eef2ff;color:#3730a3", line)
1211    } else if let Some(body) = line.strip_prefix(' ') {
1212        (" ", "", body)
1213    } else {
1214        (" ", "color:#57606a;font-weight:600", line)
1215    }
1216}
1217
1218/// The handful of words the approval panel says in its own voice.
1219///
1220/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1221/// the operator asked why the merge question spoke English on a repository
1222/// configured for Japanese, and "because that string is a literal in Rust" is
1223/// not an answer. Only the languages magi can actually check are translated;
1224/// anything else falls back to English rather than shipping a guess, and that
1225/// fallback is deliberate.
1226struct Words {
1227    html_lang: &'static str,
1228    task: &'static str,
1229    what_changed: &'static str,
1230    review_verdict: &'static str,
1231    reviewer: &'static str,
1232    reviewer_no_answer: &'static str,
1233    checks: &'static str,
1234    nothing_failing: &'static str,
1235    files_changed: &'static str,
1236    commits: &'static str,
1237    no_commits: &'static str,
1238    comments: &'static str,
1239    no_comments: &'static str,
1240    diff: &'static str,
1241    truncated: &'static str,
1242    lands_as: &'static str,
1243}
1244
1245const EN: Words = Words {
1246    html_lang: "en",
1247    task: "Task",
1248    what_changed: "What changed",
1249    review_verdict: "Review verdict",
1250    reviewer: "Reviewer",
1251    reviewer_no_answer: "produced no answer",
1252    checks: "Checks",
1253    nothing_failing: "Nothing failing.",
1254    files_changed: "file(s) changed",
1255    commits: "Commits being squashed",
1256    no_commits: "No commit subjects could be read from the branch.",
1257    comments: "External PR review comments",
1258    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1259    diff: "Diff",
1260    truncated: "Truncated",
1261    lands_as: "They land as one commit titled",
1262};
1263
1264const JA: Words = Words {
1265    html_lang: "ja",
1266    task: "タスク",
1267    what_changed: "変更内容",
1268    review_verdict: "レビューの結論",
1269    reviewer: "レビュアー",
1270    reviewer_no_answer: "回答なし",
1271    checks: "チェック",
1272    nothing_failing: "失敗しているものはありません。",
1273    files_changed: "ファイル変更",
1274    commits: "squash されるコミット",
1275    no_commits: "ブランチからコミット件名を読めませんでした。",
1276    comments: "外部 PR レビューコメント",
1277    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1278    diff: "差分",
1279    truncated: "省略",
1280    lands_as: "これらは次の件名の1コミットとして入ります:",
1281};
1282
1283impl Words {
1284    /// The clause after the merge subject. Split out because word order moves:
1285    /// Japanese puts the subject before the verb, so a shared template with a
1286    /// hole in the middle would read as machine translation.
1287    fn lands_as_tail(&self) -> &'static str {
1288        if self.html_lang == "ja" {
1289            "。この件名も承認の対象です。"
1290        } else {
1291            ", which you are approving too."
1292        }
1293    }
1294
1295    /// The question's own one-line summary, which is what a phone shows first.
1296    fn approval_summary(&self, number: u64, subject: &str) -> String {
1297        if self.html_lang == "ja" {
1298            format!("プルリクエスト #{number} をマージ: {subject}")
1299        } else {
1300            format!("merge pull request #{number}: {subject}")
1301        }
1302    }
1303
1304    /// The body under the summary, above the panel.
1305    fn approval_detail(
1306        &self,
1307        url: &str,
1308        base: &str,
1309        subject: &str,
1310        contested: Option<&ContestedHandoff>,
1311    ) -> String {
1312        let body = if self.html_lang == "ja" {
1313            format!(
1314                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1315                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1316                 下のパネルにあります。"
1317            )
1318        } else {
1319            format!(
1320                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1321                 The panel holds the diffstat, the patch and the commits being squashed."
1322            )
1323        };
1324        match contested {
1325            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1326            None => body,
1327        }
1328    }
1329
1330    /// Why this question exists although merge approvals are off: the open
1331    /// blocking findings and who rejected. Short enough for a phone.
1332    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1333        const SHOWN: usize = 5;
1334        const TITLE_CHARS: usize = 100;
1335        let ja = self.html_lang == "ja";
1336        let mut out = if ja {
1337            format!(
1338                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1339                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1340            )
1341        } else {
1342            format!(
1343                "{url} is held for approval although merge approvals are off: the \
1344                 review ended with blocking findings still open and a reviewer \
1345                 voting reject.\n"
1346            )
1347        };
1348        for f in c.findings.iter().take(SHOWN) {
1349            let at = match (&f.file, f.line) {
1350                (Some(file), Some(line)) => format!("{file}:{line}"),
1351                (Some(file), None) => file.clone(),
1352                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1353            };
1354            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1355            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1356        }
1357        if c.findings.len() > SHOWN {
1358            let more = c.findings.len() - SHOWN;
1359            let _ = writeln!(
1360                out,
1361                "{}",
1362                if ja {
1363                    format!("- ほか {more} 件")
1364                } else {
1365                    format!("- and {more} more")
1366                }
1367            );
1368        }
1369        let seats: Vec<String> = c
1370            .rejecters
1371            .iter()
1372            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1373            .collect();
1374        let _ = write!(
1375            out,
1376            "{} {}",
1377            if ja {
1378                "却下したレビュアー:"
1379            } else {
1380                "Rejected by reviewer:"
1381            },
1382            seats.join(", ")
1383        );
1384        out
1385    }
1386
1387    /// The truncation note, written whole in each language for the same reason.
1388    fn truncated_note(
1389        &self,
1390        omitted: usize,
1391        total: usize,
1392        shown: usize,
1393        where_: &str,
1394        base: &str,
1395        head: &str,
1396    ) -> String {
1397        if self.html_lang == "ja" {
1398            format!(
1399                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1400                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1401                 プルリクエストにあります。"
1402            )
1403        } else {
1404            format!(
1405                "{omitted} of {total} diff lines omitted after the first {shown}. \
1406                 The whole patch is in <code>{where_}</code> \
1407                 (<code>git diff {base}...{head}</code>) and on the pull request."
1408            )
1409        }
1410    }
1411}
1412
1413/// Pick the panel's language. Codes and names both, because `[graph] language`
1414/// has always accepted either.
1415fn words(language: &str) -> &'static Words {
1416    if crate::lang::is_japanese(language) {
1417        &JA
1418    } else {
1419        &EN
1420    }
1421}
1422
1423/// The approval panel's html: what is about to land, and the evidence for it.
1424///
1425/// Pure, so the whole document is asserted in tests without `gh`, without a
1426/// network and without a repository. The caller gathers `diffstat`
1427/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1428/// being squashed) and `subject` (what the squash will be called) from the
1429/// winner's worktree.
1430///
1431/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1432/// content security policy blocks all three: anything of the sort here would be
1433/// dead markup that misleads the next reader into thinking it works.
1434pub fn approval_panel(
1435    state: &RunState,
1436    pr: &PrState,
1437    diffstat: &str,
1438    diff: &str,
1439    commits: &[String],
1440    subject: &str,
1441) -> String {
1442    let rows = parse_numstat(diffstat);
1443    let w = words(&state.config.graph.language);
1444    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1445
1446    let _ = writeln!(
1447        h,
1448        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1449         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1450        w.html_lang
1451    );
1452    let _ = writeln!(
1453        h,
1454        "<title>merge #{} — {}</title>\n</head>",
1455        pr.number,
1456        esc(subject)
1457    );
1458    h.push_str(
1459        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1460         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1461         word-break:break-word\">\n",
1462    );
1463
1464    // The decision, in the words the operator is approving.
1465    let _ = writeln!(
1466        h,
1467        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1468         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1469         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1470         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1471         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1472        pr.number,
1473        esc(&state.base_branch),
1474        esc(subject),
1475        esc(&state.id),
1476        esc(&pr.url),
1477        esc(&pr.url),
1478    );
1479
1480    let _ = writeln!(
1481        h,
1482        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1483        esc(&approval_evidence(state))
1484    );
1485
1486    // The task, verbatim: the operator's own words for what was asked, so the
1487    // panel does not make them reconstruct the request from a diffstat.
1488    let _ = writeln!(
1489        h,
1490        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1491         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1492        w.task,
1493        esc(&state.instruction)
1494    );
1495
1496    // The winner's own account of what it did and why, when there is one.
1497    if let Some(summary) = state
1498        .winner()
1499        .map(|c| c.summary.as_str())
1500        .filter(|s| !s.is_empty())
1501    {
1502        let _ = writeln!(
1503            h,
1504            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1505             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1506            w.what_changed,
1507            esc(summary)
1508        );
1509    }
1510
1511    // Only the last round's reviewer prose is current. Its votes and
1512    // remaining findings are shown explicitly in the evidence above.
1513    if let Some(round) = state.reviews.last() {
1514        let _ = writeln!(
1515            h,
1516            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1517            w.review_verdict
1518        );
1519        for r in &round.reviews {
1520            // A seat the review loop counted as answered has real prose in
1521            // `summary`; one it counted against `incomplete` (see
1522            // `graph::Runner::review_loop`) never produced any and left it
1523            // empty - which must not be read back as a blank verdict, since
1524            // an empty box here looks like "nothing to say" rather than
1525            // "never answered".
1526            let body = match &r.failed {
1527                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1528                None => esc(&r.summary),
1529            };
1530            let _ = writeln!(
1531                h,
1532                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1533                 border-radius:6px\">\
1534                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1535                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1536                w.reviewer,
1537                r.reviewer,
1538                esc(&r.agent),
1539                body,
1540            );
1541        }
1542    }
1543
1544    let _ = writeln!(
1545        h,
1546        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1547        w.checks,
1548        esc(pr.checks.as_str())
1549    );
1550    if pr.failing.is_empty() {
1551        let _ = writeln!(
1552            h,
1553            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1554            w.nothing_failing
1555        );
1556    } else {
1557        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1558        for f in &pr.failing {
1559            let _ = writeln!(h, "<li>{}</li>", esc(f));
1560        }
1561        h.push_str("</ul>\n");
1562    }
1563
1564    // Diffstat as a real table, so a phone reads what moved without scrolling
1565    // sideways through a terminal bar chart.
1566    let _ = writeln!(
1567        h,
1568        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1569        rows.len(),
1570        w.files_changed
1571    );
1572    h.push_str(
1573        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1574         <thead><tr>\
1575         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1576         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1577         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1578         </th></tr></thead>\n<tbody>\n",
1579    );
1580    let mut total_added = 0u64;
1581    let mut total_removed = 0u64;
1582    for r in &rows {
1583        total_added += r.added.unwrap_or(0);
1584        total_removed += r.removed.unwrap_or(0);
1585        let cell = |n: Option<u64>| match n {
1586            Some(n) => n.to_string(),
1587            None => "bin".to_owned(),
1588        };
1589        let _ = writeln!(
1590            h,
1591            "<tr>\
1592             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1593             font-family:ui-monospace,monospace\">{}</td>\
1594             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1595             color:#0a3622\">{}</td>\
1596             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1597             color:#5c1a17\">{}</td></tr>",
1598            esc(&r.path),
1599            cell(r.added),
1600            cell(r.removed),
1601        );
1602    }
1603    let _ = writeln!(
1604        h,
1605        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1606         <td style=\"padding:4px 2px\">total</td>\
1607         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1608         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1609         </tr></tfoot>\n</table>"
1610    );
1611
1612    // The commits being squashed, and the subject that replaces them.
1613    let _ = writeln!(
1614        h,
1615        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1616        w.commits
1617    );
1618    if commits.is_empty() {
1619        h.push_str(&format!(
1620            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1621            w.no_commits
1622        ));
1623    } else {
1624        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1625        for c in commits {
1626            let _ = writeln!(h, "<li>{}</li>", esc(c));
1627        }
1628        h.push_str("</ol>\n");
1629    }
1630    let _ = writeln!(
1631        h,
1632        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1633        w.lands_as,
1634        esc(subject),
1635        w.lands_as_tail()
1636    );
1637
1638    // The review comments that shaped this branch, and who asked for them.
1639    let _ = writeln!(
1640        h,
1641        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1642        w.comments
1643    );
1644    if pr.review_comments.is_empty() {
1645        h.push_str(&format!(
1646            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1647            w.no_comments
1648        ));
1649    } else {
1650        for c in &pr.review_comments {
1651            let anchor = match (&c.path, c.line) {
1652                (Some(p), Some(l)) => format!("{p}:{l}"),
1653                (Some(p), None) => p.clone(),
1654                _ => "pull request thread".to_owned(),
1655            };
1656            let _ = writeln!(
1657                h,
1658                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1659                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1660                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1661                esc(&c.author),
1662                esc(&anchor),
1663                esc(&tail(&c.body, 800)),
1664            );
1665        }
1666    }
1667
1668    // The patch itself.
1669    let total = diff.lines().count();
1670    let shown = total.min(DIFF_MAX_LINES);
1671    let _ = writeln!(
1672        h,
1673        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1674        w.diff
1675    );
1676    h.push_str(
1677        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1678         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1679    );
1680    for line in diff.lines().take(shown) {
1681        let (gutter, style, body) = diff_row(line);
1682        let _ = writeln!(
1683            h,
1684            "<div style=\"display:flex;{style}\">\
1685             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1686             border-right:1px solid #d0d7de\">{gutter}</span>\
1687             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1688            esc(body),
1689        );
1690    }
1691    h.push_str("</div>\n");
1692    if total > shown {
1693        let omitted = total - shown;
1694        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1695        let where_ = state.winner().map_or_else(
1696            || state.repo.display().to_string(),
1697            |w| w.worktree.display().to_string(),
1698        );
1699        let _ = writeln!(
1700            h,
1701            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1702             font-size:13px\">{}: {}</p>",
1703            w.truncated,
1704            w.truncated_note(
1705                omitted,
1706                total,
1707                shown,
1708                &esc(&where_),
1709                &esc(&state.base_branch),
1710                &esc(head),
1711            ),
1712        );
1713    }
1714
1715    h.push_str("</body>\n</html>\n");
1716    h
1717}
1718
1719/// Historical contested hand-off evidence for the question detail. Approval
1720/// itself is decided by `approval_reasons`, including reject-only cases.
1721fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1722    if state.config.graph.hold_contested_merge {
1723        state.contested_handoff.clone()
1724    } else {
1725        None
1726    }
1727}
1728
1729/// What a merge-approval question's deputy is told: the pull request, the run,
1730/// what each answer does, and the evidence saved when the question was filed.
1731///
1732/// A snapshot taken when the deputy is attached, so it says so and points at
1733/// `magi show` / `gh pr view` for anything current. `state` is `None` for a run
1734/// that cannot be read; what is missing is named as missing, and no past
1735/// approval basis is rebuilt from today's state.
1736pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1737    let mut s = format!(
1738        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1739         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1740         it into the base branch, which cannot be undone; `{HOLD}` leaves the pull \
1741         request open. Silence is a hold: the owner not answering never merges. Only \
1742         the owner choosing `{APPROVE}`, or clearly telling you to merge in their \
1743         own words, merges. Whether their wording is a clear, unconditional instruction \
1744         is your judgement alone: if it is doubtful, conditional, retracted or a \
1745         question, do not settle - ask back with `magi ask --thread`. Doubt and \
1746         silence are a hold.\n\n\
1747         This brief is a snapshot from when you were attached: check `magi show {run}` \
1748         and `gh pr view` (read-only) before telling the owner anything current. \
1749         You run with permission to write the question record, and what keeps you \
1750         from touching anything else is this brief and your instructions - so do \
1751         not change files, branches or the pull request.",
1752        run = q.run
1753    );
1754    if let Some(target) = &q.merge_target {
1755        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. Merge through chat requires the owner's latest whole message and quote to be exactly `merge {}`. Discussion alone never approves. For hold, repair, withdrawal or replacement, use the exact offered token from the owner's latest whole message. Withdrawal explicitly closes the PR and ends the task. Replacement closes the old PR before dispatching a new attempt with the selected implementer; do not start a separate task yourself.", target.pr, target.head, target.head));
1756    }
1757    if q.choices.iter().any(|c| c == REPAIR) {
1758        s.push_str("\n\n`fix findings and re-review` requests one repair and bounded re-review on the existing PR. It never approves a merge. Settle this choice only when the owner's whole latest message is exactly this label; otherwise discuss it with `--thread`. The driver performs the repair; do not edit or queue another task. Updated review and gate results require a fresh merge decision.");
1759    }
1760    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1761    {
1762        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1763        s.push_str(&q.detail);
1764    } else {
1765        s.push_str("\n\nApproval reasons and final review evidence were not recorded in this legacy question. Do not infer its original trigger from current settings; consult the saved panel and review history.");
1766    }
1767    let Some(state) = state else {
1768        s.push_str(
1769            "\n\nThe run's record could not be read, so the pull request, the panel \
1770             summary and any contested findings are not known to you beyond the \
1771             question's own text. Say so to the owner rather than guessing.",
1772        );
1773        return s;
1774    };
1775    if let Some(pr) = &state.pr {
1776        s.push_str(&format!(
1777            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1778            pr.number, pr.url, pr.state
1779        ));
1780    }
1781    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1782    if let Some(w) = state.winner() {
1783        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1784    }
1785
1786    s
1787}
1788
1789// Releasing a task permits work to resume; it does not renew merge consent.
1790// Check the persisted operator decision both when reusing a card and at the
1791// final merge boundary, since a hold/release can arrive between those reads.
1792fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1793    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1794        return Ok(false);
1795    }
1796    let Some(task_id) = state
1797        .origin
1798        .as_ref()
1799        .and_then(|origin| origin.task.as_ref())
1800    else {
1801        return Ok(false);
1802    };
1803    let task = crate::queue::Queue::open()
1804        .get(task_id)
1805        .context("cannot verify operator decisions for the merge approval")?;
1806    Ok(task
1807        .operator_decided_at
1808        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1809}
1810
1811/// Ask the owner before merging, with the whole case attached as a panel.
1812///
1813/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1814/// never from the network, so a phone on a slow link gets the diff magi is
1815/// looking at rather than a link it has to go and open.
1816///
1817/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1818/// for up to a day right here, which held the whole run's task claim - and
1819/// the daemon's one slot with it - for exactly as long as the owner took to
1820/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1821/// caller park the run and hand the slot back instead: the question is on
1822/// disk either way, so nothing about the wait itself changes, only who is
1823/// blocked on it.
1824///
1825/// Idempotent across resumes: called again for a run already waiting on its
1826/// own question, this finds that question by [`crate::ask::Questions::list`]
1827/// rather than filing a second one - asking twice would double the
1828/// notification for one decision, and leave the first question's panel an
1829/// orphan nobody's answer ever reaches.
1830async fn approval_gate(
1831    state: &mut RunState,
1832    pr: &PrState,
1833    subject: &str,
1834    contested: Option<&ContestedHandoff>,
1835    head: &str,
1836) -> Result<ApprovalGate> {
1837    let store = ask::Questions::open();
1838    // An answer belongs to the commit its panel showed. A question recorded
1839    // for another head - or none recorded at all, as for a question filed
1840    // before heads were tracked - is never reused: a fix or rebase push made
1841    // a commit the owner has not seen, and their word does not carry over.
1842    let reusable = state.land_approval.as_ref().and_then(|a| {
1843        store
1844            .list()
1845            .into_iter()
1846            .find(|q| q.id == a.question)
1847            .filter(|q| {
1848                q.status != ask::QuestionStatus::Abandoned
1849                    && (a.head.eq_ignore_ascii_case(head)
1850                        || q.resolution()
1851                            .as_deref()
1852                            .is_some_and(crate::merge_decision::stops))
1853            })
1854    });
1855    let reusable = match reusable {
1856        Some(q) if merge_consent_superseded(state, &q)? => None,
1857        other => other,
1858    };
1859    if reusable.is_none() {
1860        for stale in store
1861            .list()
1862            .into_iter()
1863            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1864        {
1865            let why =
1866                "the pull request head or operator decision changed; asked again for approval";
1867            if let Err(e) = store.update(&stale.id, |q| {
1868                q.abandon(why);
1869                Ok(())
1870            }) {
1871                tracing::warn!("could not retire the superseded approval question: {e:#}");
1872            }
1873        }
1874    }
1875
1876    let q = match reusable {
1877        Some(mut q) => {
1878            if upgrade_repair_choice(&mut q, state) {
1879                q = store
1880                    .update(&q.id, |current| {
1881                        upgrade_repair_choice(current, state);
1882                        Ok(())
1883                    })?
1884                    .0;
1885            }
1886            q
1887        }
1888        None => {
1889            let worktree = match state.winner() {
1890                Some(w) => w.worktree.clone(),
1891                None => state.repo.clone(),
1892            };
1893            // The diff is built from the commit being approved, not from the
1894            // branch name, which may already point somewhere else.
1895            let head = if head.is_empty() {
1896                state
1897                    .winner()
1898                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1899            } else {
1900                head.to_owned()
1901            };
1902            // The diff is against what the remote's base holds, never the
1903            // local branch of that name; unreadable means less evidence.
1904            let remote = &state.config.merge.remote;
1905            let tracking = format!("{remote}/{}", state.base_branch);
1906            let base = if git::rev_exists(&worktree, &tracking).await {
1907                tracking
1908            } else {
1909                String::new()
1910            };
1911            let range = format!("{base}...{head}");
1912            // A failed `git` must not decide the merge: the panel degrades to
1913            // less evidence and the owner still chooses. Merging because the
1914            // diff could not be read would be the worst of both.
1915            let numstat = if base.is_empty() {
1916                String::new()
1917            } else {
1918                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1919                    .await
1920                    .map(|o| o.stdout)
1921                    .unwrap_or_default()
1922            };
1923            let diff = if base.is_empty() {
1924                String::new()
1925            } else {
1926                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1927            };
1928            let commits: Vec<String> = if base.is_empty() {
1929                Vec::new()
1930            } else {
1931                git::git_raw(
1932                    &worktree,
1933                    &[
1934                        "log",
1935                        "--reverse",
1936                        "--format=%s",
1937                        &format!("{base}..{head}"),
1938                    ],
1939                )
1940                .await
1941                .map(|o| o.stdout)
1942                .unwrap_or_default()
1943                .lines()
1944                .filter(|l| !l.trim().is_empty())
1945                .map(str::to_owned)
1946                .collect()
1947            };
1948
1949            let w = words(&state.config.graph.language);
1950            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1951            let mut fresh = ask::Question::new(
1952                state.id.clone(),
1953                APPROVAL_NODE.to_owned(),
1954                "land".to_owned(),
1955                w.approval_summary(pr.number, subject),
1956                format!(
1957                    "{}\n\n{}",
1958                    approval_evidence(state),
1959                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1960                ),
1961                approval_choices(state),
1962            );
1963            fresh.merge_target = Some(crate::merge_decision::Target {
1964                pr: pr.url.clone(),
1965                head: head.clone(),
1966                language: state.config.graph.language.clone(),
1967            });
1968            if fresh.choices.iter().any(|c| c == REPAIR) {
1969                fresh.actions.insert(
1970                    REPAIR.to_owned(),
1971                    ask::ChoiceAction::Resume {
1972                        run: state.id.clone(),
1973                    },
1974                );
1975                fresh.detail.push_str("\n\n`fix findings and re-review` runs one repair and bounded review on this PR; it does not approve a merge. A fresh decision follows the updated review and gate.");
1976            }
1977            for choice in fresh.choices.clone() {
1978                if crate::merge_decision::stops(&choice)
1979                    && choice != HOLD
1980                    && choice != crate::merge_decision::DISMISS
1981                {
1982                    fresh.actions.insert(
1983                        choice,
1984                        ask::ChoiceAction::Resume {
1985                            run: state.id.clone(),
1986                        },
1987                    );
1988                }
1989            }
1990            store
1991                .put_panel(&mut fresh, &html, &[])
1992                .context("write the merge approval panel")?;
1993            store
1994                .put_executable(&mut fresh)
1995                .context("file the merge approval question")?;
1996            state.land_approval = Some(LandApproval {
1997                question: fresh.id.clone(),
1998                head: head.clone(),
1999            });
2000            state.event(
2001                "land",
2002                format!("asking for merge approval ({})", fresh.short()),
2003            );
2004            state.save()?;
2005            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
2006                // A broken webhook is not a reason to lose the merge: the
2007                // question is already on disk and the web UI already shows
2008                // it, so the operator still has a way in.
2009                tracing::warn!(
2010                    "could not notify about merge approval question {}: {e:#} - \
2011                     the web UI is the only surface for it now",
2012                    fresh.short()
2013                );
2014            }
2015            fresh
2016        }
2017    };
2018
2019    Ok(match q.status {
2020        ask::QuestionStatus::Open => ApprovalGate::Pending,
2021        // Nobody answered before `state.config.graph.answer_timeout` passed,
2022        // or the question was closed with no decision recorded underneath
2023        // this run - either way there is nothing left to wait on.
2024        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
2025        // The merge gate does not speak `--thread`: an owner who talked back
2026        // instead of choosing never reaches `Answered`, so this arm only
2027        // ever sees an actual decision.
2028        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
2029            Approval::Merge => ApprovalGate::Approved,
2030            Approval::Repair if !state.reviews.is_empty() => {
2031                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
2032                    state.merge_repairs.push(crate::run::MergeRepair {
2033                        question: q.id.clone(),
2034                        head: head.to_owned(),
2035                        pr: pr.url.clone(),
2036                        reviews: state.reviews.clone(),
2037                        gate: state.gate.clone(),
2038                        gate_head: state.gate_head.clone(),
2039                        gate_fixes: state.gate_fixes.clone(),
2040                        pre_gate: state.pre_gate.clone(),
2041                        phase: crate::run::MergeRepairPhase::Requested,
2042                        failure: None,
2043                    });
2044                    state.land_approval = None;
2045                    state.event(
2046                        "repair",
2047                        "owner requested fix findings and re-review; merge is not approved",
2048                    );
2049                    state.save()?;
2050                }
2051                ApprovalGate::Repair
2052            }
2053            Approval::Repair | Approval::Hold => ApprovalGate::Held,
2054        },
2055    })
2056}
2057
2058/// Fold a rollup's entries into one verdict plus the failing checks' labels.
2059/// No I/O. An empty rollup is `Unknown`, never green.
2060fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
2061    let mut failing = Vec::new();
2062    let mut pending = false;
2063    let mut unknown = false;
2064    for check in rollup {
2065        match check.verdict() {
2066            Verdict::Pass => {}
2067            Verdict::Pending => pending = true,
2068            Verdict::Fail => failing.push(check.label()),
2069            Verdict::Unknown => unknown = true,
2070        }
2071    }
2072    let checks = if rollup.is_empty() {
2073        Checks::Unknown
2074    } else if pending {
2075        Checks::Pending
2076    } else if !failing.is_empty() {
2077        Checks::Red
2078    } else if unknown {
2079        Checks::Unknown
2080    } else {
2081        Checks::Green
2082    };
2083    (checks, failing)
2084}
2085
2086/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
2087/// output into a [`PrState`]. No I/O.
2088pub fn parse_pr(json: &str) -> Result<PrState> {
2089    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2090    let state = match raw.state.to_ascii_uppercase().as_str() {
2091        "OPEN" => PrLifecycle::Open,
2092        "MERGED" => PrLifecycle::Merged,
2093        "CLOSED" => PrLifecycle::Closed,
2094        other => bail!("unknown pull request state `{other}`"),
2095    };
2096
2097    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2098
2099    let mut review_comments = Vec::new();
2100    for r in raw.reviews {
2101        push_if_outstanding(
2102            &mut review_comments,
2103            ReviewComment {
2104                author: r.author.login,
2105                path: None,
2106                line: None,
2107                body: r.body,
2108            },
2109        );
2110    }
2111    for c in raw.comments {
2112        push_if_outstanding(
2113            &mut review_comments,
2114            ReviewComment {
2115                author: c.author.login,
2116                path: None,
2117                line: None,
2118                body: c.body,
2119            },
2120        );
2121    }
2122
2123    Ok(PrState {
2124        url: raw.url,
2125        number: raw.number,
2126        state,
2127        checks,
2128        failing,
2129        review_comments,
2130        blocking: Blocking::of(&raw.merge_state_status),
2131    })
2132}
2133
2134/// One rollup entry as the release watcher reads it.
2135#[derive(Debug, Clone, PartialEq, Eq)]
2136pub(crate) struct CheckView {
2137    pub name: String,
2138    pub verdict: Verdict,
2139    /// Workflow run behind the check, when its url names one.
2140    pub run: Option<String>,
2141    pub url: Option<String>,
2142}
2143
2144/// A pull request's lifecycle, head commit and per-check verdicts, without
2145/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2146/// from it, which is exactly what the release watcher must not inherit).
2147#[derive(Debug, Clone, PartialEq, Eq)]
2148pub(crate) struct RollupView {
2149    pub url: String,
2150    pub number: u64,
2151    pub state: PrLifecycle,
2152    pub head: String,
2153    pub checks: Vec<CheckView>,
2154}
2155
2156/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2157/// output. No I/O.
2158pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2159    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2160    let state = match raw.state.to_ascii_uppercase().as_str() {
2161        "OPEN" => PrLifecycle::Open,
2162        "MERGED" => PrLifecycle::Merged,
2163        "CLOSED" => PrLifecycle::Closed,
2164        other => bail!("unknown pull request state `{other}`"),
2165    };
2166    let checks = raw
2167        .status_check_rollup
2168        .iter()
2169        .map(|c| CheckView {
2170            name: c.label(),
2171            verdict: c.verdict(),
2172            run: c.url().and_then(run_of),
2173            url: c.url().map(str::to_owned),
2174        })
2175        .collect();
2176    Ok(RollupView {
2177        url: raw.url,
2178        number: raw.number,
2179        state,
2180        head: raw.head_ref_oid,
2181        checks,
2182    })
2183}
2184
2185/// Read just a pull request's lifecycle state - open, merged, or closed -
2186/// with none of the checks/reviews/comments [`land`] itself needs to decide
2187/// what to do next.
2188///
2189/// For a caller that only ever wants one fact and must not risk anything
2190/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2191/// it is actually a merged pull request *before* touching a run's state, so a
2192/// typo or a still-open PR fails loudly instead of quietly recording a merge
2193/// that never happened.
2194pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2195    let view = gh(
2196        repo,
2197        &[
2198            "pr".to_owned(),
2199            "view".to_owned(),
2200            pr_url.to_owned(),
2201            "--json".to_owned(),
2202            "state".to_owned(),
2203        ],
2204    )
2205    .await?;
2206    if !view.0 {
2207        bail!("gh pr view {pr_url}: {}", view.1);
2208    }
2209    // `parse_pr` reads every other field of `GhPr` as its serde default
2210    // (empty string, empty vec, zero) when this narrower `--json` selection
2211    // does not carry them - harmless, since only `.state` is read back.
2212    Ok(parse_pr(&view.1)?.state)
2213}
2214
2215/// A pull request the operator merged outside of `land::land`'s own loop,
2216/// found by asking GitHub about the run's own winning branch rather than
2217/// requiring the operator to go and find the URL themselves.
2218#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2219pub struct ExternalMerge {
2220    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2221    pub url: String,
2222    /// The pull request's number.
2223    pub number: u64,
2224}
2225
2226#[derive(Debug, Deserialize)]
2227#[serde(rename_all = "camelCase")]
2228struct GhMergedPr {
2229    url: String,
2230    number: u64,
2231    merged_at: String,
2232    base_ref_name: String,
2233}
2234
2235/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2236/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2237/// decide whether exactly one of the pull requests it lists could actually
2238/// be *this* run's.
2239///
2240/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2241/// from the run's own short id, so a collision with some other, unrelated
2242/// task's merged pull request from a same-named branch is rare but not
2243/// impossible once branches are deleted and ids run out. Filtering on
2244/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2245/// (which cannot predate the run itself) rules that case out. More than one
2246/// survivor is exactly as uninformative as zero — something this run cannot
2247/// tell apart from another — so only a unique survivor is returned.
2248fn pick_merged_pr(
2249    json: &str,
2250    base_branch: &str,
2251    created_at: Timestamp,
2252) -> Result<Option<ExternalMerge>> {
2253    let raw: Vec<GhMergedPr> =
2254        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2255    let mut matches: Vec<ExternalMerge> = Vec::new();
2256    for pr in raw {
2257        if pr.base_ref_name != base_branch {
2258            continue;
2259        }
2260        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2261            continue;
2262        };
2263        if merged_at < created_at {
2264            continue;
2265        }
2266        matches.push(ExternalMerge {
2267            url: pr.url,
2268            number: pr.number,
2269        });
2270    }
2271    if matches.len() == 1 {
2272        Ok(matches.pop())
2273    } else {
2274        Ok(None)
2275    }
2276}
2277
2278/// What `gh pr list --head <branch> --base <base> --state open` found.
2279#[derive(Debug, Clone, PartialEq, Eq)]
2280pub enum OpenPr {
2281    /// Nothing open: the caller creates one.
2282    None,
2283    /// Exactly one: the caller adopts it instead of creating a second.
2284    One {
2285        /// The pull request's URL.
2286        url: String,
2287        /// Its current title.
2288        title: String,
2289    },
2290    /// More than one: magi does not pick between them.
2291    Many(Vec<String>),
2292}
2293
2294#[derive(Debug, Deserialize)]
2295#[serde(rename_all = "camelCase")]
2296struct GhOpenPr {
2297    // `url` and `baseRefName` are required: a record missing either must be a
2298    // parse error, not a pull request that silently fails the base filter and
2299    // reads as "none open" (which would go on to create a duplicate).
2300    url: String,
2301    #[serde(default)]
2302    title: String,
2303    base_ref_name: String,
2304    // A fork's branch of the same name is not the task head; `gh pr list --head`
2305    // matches by branch name alone, so the filter has to be ours.
2306    #[serde(default)]
2307    is_cross_repository: bool,
2308}
2309
2310/// Pure half of [`find_open_pr`]: classify the raw `--json
2311/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2312/// dropped even though the query already filtered on it, so a stub or an old
2313/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2314/// adopted.
2315pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2316    let raw: Vec<GhOpenPr> =
2317        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2318    let mut hits: Vec<GhOpenPr> = raw
2319        .into_iter()
2320        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2321        .collect();
2322    Ok(match hits.len() {
2323        0 => OpenPr::None,
2324        1 => {
2325            let p = hits.remove(0);
2326            OpenPr::One {
2327                url: p.url,
2328                title: p.title,
2329            }
2330        }
2331        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2332    })
2333}
2334
2335/// Open pull requests whose head is `branch` and whose base is `base`. A
2336/// failing `gh` is an error carrying its own output, never "none": guessing
2337/// there is how a duplicate gets created.
2338pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2339    let (ok, out) = gh(
2340        repo,
2341        &[
2342            "pr".to_owned(),
2343            "list".to_owned(),
2344            "--head".to_owned(),
2345            branch.to_owned(),
2346            "--base".to_owned(),
2347            base.to_owned(),
2348            "--state".to_owned(),
2349            "open".to_owned(),
2350            "--json".to_owned(),
2351            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2352        ],
2353    )
2354    .await?;
2355    if !ok {
2356        bail!("gh pr list failed: {out}");
2357    }
2358    pick_open_pr(&out, base)
2359}
2360
2361#[derive(Debug, Deserialize)]
2362#[serde(rename_all = "camelCase")]
2363struct GhPrHead {
2364    head_ref_name: String,
2365    base_ref_name: String,
2366    state: String,
2367    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2368    // head lives in a fork must be a parse error, never "same repository".
2369    is_cross_repository: bool,
2370    // Required too: it is what ties the pull request to the commits that were
2371    // actually proven to be on the base.
2372    head_ref_oid: String,
2373}
2374
2375/// Why [`closable`] said no, and whether asking again later could say yes.
2376#[derive(Debug, Clone, PartialEq, Eq)]
2377pub struct Refusal {
2378    /// A later attempt may succeed (the head moved, the view was unreadable);
2379    /// `false` means this pull request is simply not the run's to close.
2380    pub retry: bool,
2381    /// What was wrong.
2382    pub why: String,
2383}
2384
2385impl Refusal {
2386    fn final_(why: String) -> Self {
2387        Self { retry: false, why }
2388    }
2389}
2390
2391/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2392/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2393/// is safe, `Err` carrying the reason when it is not.
2394///
2395/// Closing is outward-facing, so every property is checked on what the forge
2396/// says now, not on what magi recorded: the head must be exactly `branch` in
2397/// this repository (a fork's branch of the same name is somebody else's), the
2398/// base must be `base`, and the pull request must still be open.
2399pub fn closable(
2400    json: &str,
2401    branch: &str,
2402    base: &str,
2403    verified: &[String],
2404) -> std::result::Result<(), Refusal> {
2405    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2406        retry: true,
2407        why: format!("could not read the pull request ({e})"),
2408    })?;
2409    if pr.head_ref_name != branch {
2410        return Err(Refusal::final_(format!(
2411            "its head is `{}`, not this run's `{branch}`",
2412            pr.head_ref_name
2413        )));
2414    }
2415    if pr.is_cross_repository {
2416        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2417    }
2418    if pr.base_ref_name != base {
2419        return Err(Refusal::final_(format!(
2420            "it targets `{}`, not `{base}`",
2421            pr.base_ref_name
2422        )));
2423    }
2424    if !pr.state.eq_ignore_ascii_case("open") {
2425        return Err(Refusal::final_(format!(
2426            "it is already {}",
2427            pr.state.to_ascii_lowercase()
2428        )));
2429    }
2430    // Last, so a pull request that is not this run's at all is reported as
2431    // such. A head that is this branch but not a commit checked against the
2432    // base (somebody pushed since) may well get checked next time: retry.
2433    if !verified.contains(&pr.head_ref_oid) {
2434        return Err(Refusal {
2435            retry: true,
2436            why: format!(
2437                "its head {} is not a commit this run checked against the base",
2438                crate::already::short_sha(&pr.head_ref_oid)
2439            ),
2440        });
2441    }
2442    Ok(())
2443}
2444
2445/// Does `remote` point at something a forge could host - a URL or an scp-style
2446/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2447/// from the URL alone, so it answers the same on every machine, whatever `gh`
2448/// happens to be installed or logged in to.
2449async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2450    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2451        return false;
2452    };
2453    is_forge_url(url.trim())
2454}
2455
2456fn is_forge_url(url: &str) -> bool {
2457    url.contains("://") && !url.starts_with("file://")
2458        || url
2459            .split_once(':')
2460            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2461}
2462
2463/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2464/// request that failed?
2465fn forge_unavailable(message: &str) -> bool {
2466    message.contains("known GitHub host") || message.contains("spawn gh")
2467}
2468
2469/// The comment left on a pull request closed because its change is already on
2470/// the base.
2471pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2472    let how = match evidence.proof {
2473        crate::already::Proof::PatchId => format!(
2474            "carried by commit {} on `{base}` with the same patch",
2475            evidence.names()
2476        ),
2477        crate::already::Proof::Ancestry => {
2478            format!("already in the history of `{base}` as {}", evidence.names())
2479        }
2480        crate::already::Proof::Tree => format!(
2481            "already part of `{base}` (merging this branch changes nothing at {})",
2482            crate::already::short_sha(&evidence.tip)
2483        ),
2484    };
2485    format!(
2486        "Closing: everything this branch adds is {how}, so there is nothing left to \
2487         land. This pull request was closed automatically after that was verified; \
2488         reopen it if you disagree."
2489    )
2490}
2491
2492/// Close the open pull request for `branch`, if there is one and it is
2493/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2494/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2495/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2496/// failed `gh` call, a head that moved since it was checked), which the caller
2497/// must not treat as settled.
2498///
2499/// The recorded `state.pr` is preferred, else the forge is asked for an open
2500/// pull request on `branch`; either way the candidate is re-read and passed
2501/// through [`closable`] before anything is written; `verified` lists the
2502/// commits proven to be on the base, and the pull request's head must be one.
2503pub async fn close_superseded_pr(
2504    state: &mut RunState,
2505    branch: &str,
2506    evidence: &crate::already::Evidence,
2507    verified: &[String],
2508) -> Result<std::result::Result<String, String>> {
2509    let repo = state.repo.clone();
2510    let base = state.base_branch.clone();
2511    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2512        Some(p) => p.url.clone(),
2513        // No recorded pull request. A forge that cannot be asked at all (no
2514        // GitHub remote, no `gh`) says nothing about this run, so that is
2515        // "none found"; any other lookup failure is an error, because "could
2516        // not look" is not "nothing there" and the caller must retry.
2517        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2518            return Ok(Err(
2519                "the remote is not a forge, so there is no pull request".to_owned(),
2520            ));
2521        }
2522        None => match find_open_pr(&repo, branch, &base).await {
2523            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2524                return Ok(Err(format!("no forge to ask: {e:#}")));
2525            }
2526            Err(e) => return Err(e),
2527            Ok(OpenPr::One { url, .. }) => url,
2528            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2529            Ok(OpenPr::Many(urls)) => {
2530                return Ok(Err(format!(
2531                    "{} open pull requests name it; not choosing between them",
2532                    urls.len()
2533                )));
2534            }
2535        },
2536    };
2537    let (ok, view) = gh(
2538        &repo,
2539        &[
2540            "pr".to_owned(),
2541            "view".to_owned(),
2542            url.clone(),
2543            "--json".to_owned(),
2544            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2545        ],
2546    )
2547    .await?;
2548    if !ok {
2549        bail!("gh pr view {url} failed: {view}");
2550    }
2551    if let Err(refusal) = closable(&view, branch, &base, verified) {
2552        // A pull request whose head cannot be tied to what was proven is not
2553        // one to walk away from: the caller keeps the run resumable.
2554        if refusal.retry {
2555            bail!("left {url} open: {}", refusal.why);
2556        }
2557        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2558    }
2559    let (ok, out) = gh(
2560        &repo,
2561        &[
2562            "pr".to_owned(),
2563            "close".to_owned(),
2564            url.clone(),
2565            "--comment".to_owned(),
2566            superseded_comment(&base, evidence),
2567        ],
2568    )
2569    .await?;
2570    if !ok {
2571        bail!("gh pr close {url} failed: {out}");
2572    }
2573    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2574        p.state = "closed".to_owned();
2575    }
2576    Ok(Ok(url))
2577}
2578
2579/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2580/// differs from the one this run computed. Only the title: the body may have
2581/// been edited by the owner and cannot be compared.
2582pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2583    let (title, _) = crate::github_text::prepare(state, title, "");
2584    edit_pr_title(repo, url, &title).await
2585}
2586
2587/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2588/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2589/// gate is skipped on purpose.
2590pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2591    edit_pr_title(repo, url, title).await
2592}
2593
2594/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2595/// unmodified and without the posting gate.
2596pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2597    let (ok, _) = gh(
2598        repo,
2599        &[
2600            "pr".to_owned(),
2601            "edit".to_owned(),
2602            url.to_owned(),
2603            "--title".to_owned(),
2604            title.to_owned(),
2605            "--body".to_owned(),
2606            body.to_owned(),
2607        ],
2608    )
2609    .await
2610    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2611    if !ok {
2612        // Command output may echo the chosen text; keep it out of run events.
2613        bail!("gh pr edit failed while posting the chosen rewrite");
2614    }
2615    Ok(())
2616}
2617
2618async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2619    let (ok, out) = gh(
2620        repo,
2621        &[
2622            "pr".to_owned(),
2623            "edit".to_owned(),
2624            url.to_owned(),
2625            "--title".to_owned(),
2626            title.to_owned(),
2627        ],
2628    )
2629    .await?;
2630    if !ok {
2631        bail!("gh pr edit failed: {out}");
2632    }
2633    Ok(())
2634}
2635
2636/// Ask GitHub whether this run's winning candidate branch was actually merged
2637/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2638/// --merged` exists to close, minus the operator having to find the URL by
2639/// hand.
2640///
2641/// `Ok(None)` covers every case where nothing can be said with confidence: no
2642/// winner decided yet (nothing to check a branch for), no merged pull request
2643/// found, or [`pick_merged_pr`] found more than one candidate and would not
2644/// guess between them. Never wired to a weaker, URL-less signal like
2645/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2646/// separately, precisely because it cannot drive an automatic correction on
2647/// its own (see that function's own doc).
2648pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2649    let Some(winner) = state.winner() else {
2650        return Ok(None);
2651    };
2652    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2653    let out = gh(
2654        &state.repo,
2655        &[
2656            "pr".to_owned(),
2657            "list".to_owned(),
2658            "--head".to_owned(),
2659            branch.clone(),
2660            "--state".to_owned(),
2661            "merged".to_owned(),
2662            "--json".to_owned(),
2663            "url,number,mergedAt,baseRefName".to_owned(),
2664        ],
2665    )
2666    .await?;
2667    if !out.0 {
2668        bail!("gh pr list --head {branch}: {}", out.1);
2669    }
2670    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2671}
2672
2673/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2674/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2675///
2676/// Deliberately never consulted by [`find_external_merge`]: a base branch
2677/// that has moved since the run started can make an old, abandoned branch
2678/// look like an ancestor of the *current* base for reasons that have nothing
2679/// to do with a merge (a later commit that happens to supersede it, an
2680/// unrelated squash), and there is no pull request URL here to confirm
2681/// against or to land through anyway. Its only honest use is a weaker
2682/// notice — "this looks merged, go check" — never an automatic rewrite of
2683/// `status`/`merge`.
2684pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2685    let out = tokio::process::Command::new("git")
2686        .args(["merge-base", "--is-ancestor", branch, base_branch])
2687        .current_dir(repo)
2688        .quiet()
2689        .stdin(std::process::Stdio::null())
2690        .output()
2691        .await
2692        .context("spawn git merge-base --is-ancestor")?;
2693    Ok(out.status.success())
2694}
2695
2696/// Parse `host/owner/repo` out of a forge URL, with no network access.
2697///
2698/// The host is part of the slug, not discarded: `owner/repo` alone would
2699/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2700/// repository, which is exactly the mix-up the same-repo guard exists to
2701/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2702/// shape at all.
2703fn forge_slug(url: &str) -> Option<(String, &str)> {
2704    let rest = url.rsplit("://").next()?;
2705    let (host, path) = rest.split_once('/')?;
2706    if host.is_empty() {
2707        return None;
2708    }
2709    Some((host.to_ascii_lowercase(), path))
2710}
2711
2712/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2713/// access - the first half of the same-repo guard [`correct_manual_merge`]
2714/// applies before it writes anything.
2715///
2716/// Returns `None` for anything that does not look like
2717/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2718/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2719pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2720    let (host, path) = forge_slug(url)?;
2721    let mut segments = path.split('/');
2722    let owner = segments.next()?;
2723    let repo = segments.next()?;
2724    let kind = segments.next()?;
2725    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2726        return None;
2727    }
2728    Some(format!("{host}/{owner}/{repo}"))
2729}
2730
2731/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2732/// suffix), the shape `gh repo view --json url` returns - the other half of
2733/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2734fn slug_of_repo_url(url: &str) -> Option<String> {
2735    let (host, path) = forge_slug(url)?;
2736    let mut segments = path.split('/');
2737    let owner = segments.next()?;
2738    let repo = segments.next()?;
2739    if owner.is_empty() || repo.is_empty() {
2740        return None;
2741    }
2742    Some(format!("{host}/{owner}/{repo}"))
2743}
2744
2745/// Refuse to correct a run against a pull request from a different
2746/// repository than the one it is recorded against.
2747///
2748/// This is the guard the shun/8c75 incident argued for: an operator ran
2749/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2750/// in a different repository, omitted the run id, and the id defaulted to
2751/// this machine's most recently created run - an unrelated, still-in-progress
2752/// run in a completely different repository - which then had its `status`
2753/// rewritten to `merged` from a pull request it had nothing to do with.
2754/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2755/// CLI help), but a mistyped or stale id could still name a run in a
2756/// different repository than the one the URL belongs to, so this checks that
2757/// independently rather than trusting the id alone.
2758///
2759/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2760/// mismatch names both slugs rather than just refusing, so an operator whose
2761/// local checkout's `origin` is a fork of the repository the pull request was
2762/// opened against (a legitimate setup this cannot tell apart from a genuine
2763/// mix-up) can judge for themselves rather than being blocked with no way to
2764/// see why.
2765pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2766    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2767        return Ok(());
2768    }
2769    bail!(
2770        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2771         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2772         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2773         verify by hand before treating this as a false positive)"
2774    );
2775}
2776
2777/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2778/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2779///
2780/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2781/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2782/// repository the same way GitHub itself would recognize it, so the
2783/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2784/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2785/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2786/// a same-named one on a GitHub Enterprise host.
2787async fn repo_slug(repo: &Path) -> Result<String> {
2788    let out = gh(
2789        repo,
2790        &[
2791            "repo".to_owned(),
2792            "view".to_owned(),
2793            "--json".to_owned(),
2794            "url".to_owned(),
2795        ],
2796    )
2797    .await?;
2798    if !out.0 {
2799        bail!("gh repo view --json url: {}", out.1);
2800    }
2801    #[derive(Debug, Deserialize)]
2802    struct GhRepo {
2803        url: String,
2804    }
2805    let parsed: GhRepo = serde_json::from_str(&out.1)
2806        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2807    slug_of_repo_url(&parsed.url)
2808        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2809}
2810
2811/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2812/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2813/// would have written them had magi opened and merged this pull request
2814/// itself.
2815///
2816/// This is `magi fold --merged`'s whole implementation, and also what the
2817/// web `fold-merged` route calls once it has a URL in hand — an operator
2818/// recovery path for a merge magi could not finish on its own: a PR title too
2819/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2820/// closed by hand with a pull request magi never opened and so never
2821/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2822/// directly keeps this one authoritative: a merged pull request decides
2823/// `Step::Done { merged: true }` on the very first read, before any of
2824/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2825/// safe to call here even though this pull request was never magi's own.
2826///
2827/// [`ensure_same_repo`] is checked before anything else: a pull request from
2828/// a different repository than the one `state` is recorded against is
2829/// refused outright, regardless of its lifecycle. This is the guard for a
2830/// URL an *operator* hands in - the CLI or the web route - where a stale or
2831/// mistyped run id could otherwise get corrected from an unrelated
2832/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2833/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2834/// goes through [`correct_confirmed_external_merge`] instead, which skips
2835/// this check: its `url` was never operator-supplied, it comes from
2836/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2837/// it is already guaranteed to name a pull request in that same repository -
2838/// re-deriving and re-checking the repository here would only be a second
2839/// `gh repo view` call that can fail for reasons that have nothing to do with
2840/// correctness (a rate limit, a network blip), turning a self-heal that would
2841/// otherwise have succeeded into a run left `Blocked` for another pass.
2842///
2843/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2844/// URL fails loudly without writing anything, rather than handing an open
2845/// pull request to the full autonomous loop by accident.
2846///
2847/// Correcting `status` this way does not run `bump::after_merge`
2848/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2849/// which this path never goes through. A release version bump the change
2850/// might have earned is therefore not filed automatically and has to be
2851/// requested by hand - recorded as an event on the run so the gap is visible
2852/// to whoever reads it later, not just wherever this was called from. Follow-up
2853/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2854///
2855/// Returns the status before and after, so every caller (CLI, janitor, web
2856/// route) can build its own log line or response from the same pair rather
2857/// than each re-deriving it.
2858pub async fn correct_manual_merge(
2859    state: &mut RunState,
2860    url: &str,
2861) -> Result<(RunStatus, RunStatus)> {
2862    let Some(pr_slug) = slug_of_pr_url(url) else {
2863        bail!(
2864            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2865             this pull request belongs to"
2866        );
2867    };
2868    let run_slug = repo_slug(&state.repo).await?;
2869    ensure_same_repo(&run_slug, &pr_slug)?;
2870    correct_merge(state, url).await
2871}
2872
2873/// The janitor's own entry point into the same correction
2874/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2875/// same-repo guard - see that function's own doc for why skipping it here is
2876/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2877/// calls this with a `url` [`find_external_merge`] already found by querying
2878/// `state.repo`'s own remote, so the guard could never do anything here but
2879/// fail on its own transient errors.
2880///
2881/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2882/// because it lives in a different module.
2883pub(crate) async fn correct_confirmed_external_merge(
2884    state: &mut RunState,
2885    url: &str,
2886) -> Result<(RunStatus, RunStatus)> {
2887    correct_merge(state, url).await
2888}
2889
2890/// Same pull request, same repository: the url, or the number within one repo.
2891fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2892    let Some(pr) = a.pr.as_ref() else {
2893        return false;
2894    };
2895    if !url.is_empty()
2896        && pr
2897            .url
2898            .trim_end_matches('/')
2899            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2900    {
2901        return true;
2902    }
2903    number > 0
2904        && pr.number == number
2905        && match (a.repo.canonicalize(), repo.canonicalize()) {
2906            (Ok(x), Ok(y)) => x == y,
2907            _ => a.repo == repo,
2908        }
2909}
2910
2911/// Rewrite `pr.state` to a final state on every run record under `home` that
2912/// `decide` picks, and report how many were rewritten.
2913///
2914/// Lifecycle writes through to other runs are deliberately narrow (task-head
2915/// ownership is recorded separately): only a **terminal** run (never one a
2916/// driver may still be writing), only a proven-stopped driver with no daemon
2917/// claim (the shared liveness judge), only a record
2918/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2919/// The record is read as folding reads it (no schema check: every bump so far
2920/// only added fields, and the whole struct round-trips) and written through
2921/// [`RunState::save_under`], the path every record uses, so nothing but
2922/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2923/// A run that cannot be read or written is skipped with a warning.
2924fn rewrite_open_prs(
2925    home: &Path,
2926    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2927) -> usize {
2928    let now = Timestamp::now();
2929    let mut changed = 0;
2930    for id in crate::run::list_ids_in(&home.join("runs")) {
2931        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2932            continue;
2933        };
2934        let path = home.join("runs").join(&id).join("run.json");
2935        let Ok(body) = std::fs::read_to_string(&path) else {
2936            continue;
2937        };
2938        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2939            continue;
2940        };
2941        if !state.status.done()
2942            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2943            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2944                != crate::run::Liveness::Dead
2945        {
2946            continue;
2947        }
2948        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2949            continue;
2950        };
2951        if let Some(pr) = state.pr.as_mut() {
2952            pr.state = to.as_str().to_owned();
2953        }
2954        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2955        state.event(
2956            "land",
2957            format!("recorded {url} as {}: another run settled it", to.as_str()),
2958        );
2959        match state.save_under(home) {
2960            Ok(()) => changed += 1,
2961            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2962        }
2963    }
2964    changed
2965}
2966
2967/// A run reached a final state for its pull request: tell every other terminal
2968/// run in the same repository that names the same pull request (handed-over
2969/// predecessors, blocked attempts, anything), so their records stop saying
2970/// `open`. Best effort; `run`'s own record is the caller's.
2971pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2972    if to == PrLifecycle::Open {
2973        return;
2974    }
2975    let Some(home) = crate::run::try_home() else {
2976        return;
2977    };
2978    write_pr_state_through_in(&home, run, to);
2979}
2980
2981pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2982    let Some(pr) = run.pr.as_ref() else {
2983        return 0;
2984    };
2985    let (url, number) = (pr.url.clone(), pr.number);
2986    rewrite_open_prs(home, &mut |other| {
2987        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2988    })
2989}
2990
2991/// Terminal runs whose record still says their pull request is open, as
2992/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2993pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2994    let now = Timestamp::now();
2995    let mut out = Vec::new();
2996    for id in crate::run::list_ids_in(&home.join("runs")) {
2997        let path = home.join("runs").join(&id).join("run.json");
2998        let Ok(body) = std::fs::read_to_string(&path) else {
2999            continue;
3000        };
3001        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
3002            continue;
3003        };
3004        if let Some(pr) = state.pr.as_ref()
3005            && state.status.done()
3006            && pr.state == "open"
3007            && !pr.url.is_empty()
3008            && !crate::daemon::is_working_on(home, &id, now)
3009        {
3010            out.push((id, state.repo.clone(), pr.url.clone()));
3011        }
3012    }
3013    out
3014}
3015
3016/// Apply forge answers (`pr url -> state`) to every stale terminal record.
3017/// A url with no answer (the forge was unreadable) changes nothing.
3018pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
3019    rewrite_open_prs(home, &mut |s| {
3020        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
3021    })
3022}
3023
3024/// One-time (and idempotent) repair of records that froze an `open` pull
3025/// request: ask the forge about each distinct pull request that a terminal run
3026/// still calls open - at most `max_lookups` of them - and rewrite the merged
3027/// and closed ones. A genuinely open pull request is left alone, and a lookup
3028/// that fails is "unknown, change nothing". Returns `(records rewritten,
3029/// lookups that failed)`.
3030pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
3031    let mut known = BTreeMap::new();
3032    let mut failed = 0;
3033    let mut seen = BTreeSet::new();
3034    for (_, repo, url) in stale_open_prs(home) {
3035        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
3036            continue;
3037        }
3038        match lifecycle(&repo, &url).await {
3039            Ok(state) => {
3040                known.insert(url, state);
3041            }
3042            Err(e) => {
3043                tracing::warn!("repair pr state of {url}: {e:#}");
3044                failed += 1;
3045            }
3046        }
3047    }
3048    (apply_pr_states(home, &known), failed)
3049}
3050
3051async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
3052    match lifecycle(&state.repo, url).await? {
3053        PrLifecycle::Merged => {}
3054        other => bail!(
3055            "{url} is {}, not merged; refusing to record {} as merged on a guess",
3056            other.as_str(),
3057            state.id
3058        ),
3059    }
3060    let before = state.status;
3061    if let Err(e) = land(state, url).await {
3062        // `land` sets `status` to `Landing` and saves before its first read
3063        // of the pull request — see its own doc — so a failure here (a
3064        // transient `gh` hiccup between the two forge reads this function
3065        // makes) can leave the run stuck on that in-between value with
3066        // nothing left driving it. Land it on the same terminal shape an
3067        // automated `land` failure lands on instead of leaving it stuck.
3068        state.status = RunStatus::Blocked;
3069        state.event("fold", format!("manual-merge correction failed: {e:#}"));
3070        state.save()?;
3071        return Err(e).context(format!("confirming the merge of {url}"));
3072    }
3073    state.event(
3074        "fold",
3075        "operator recorded this pull request as a manual merge; this run never \
3076         re-entered `land`, so `bump::after_merge` did not run for it - a release \
3077         bump this change might warrant has to be filed by hand",
3078    );
3079    // Unlike the bump, the findings the merge left open are filed on this
3080    // path too: nothing else would ever carry them forward.
3081    if state.status == RunStatus::Merged {
3082        crate::followup::after_merge(state, url).await;
3083    }
3084    state.save()?;
3085    Ok((before, state.status))
3086}
3087
3088/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3089/// comments. No I/O.
3090///
3091/// `gh pr view` does not surface inline comments, and inline is exactly where
3092/// both review bots put their findings - a landing loop that read only the
3093/// top-level thread would never see the thing it is supposed to fix.
3094pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3095    let raw: Vec<GhInline> =
3096        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3097    let mut out = Vec::new();
3098    for c in raw {
3099        push_if_outstanding(
3100            &mut out,
3101            ReviewComment {
3102                author: c.user.login,
3103                path: c.path,
3104                line: c.line,
3105                body: c.body,
3106            },
3107        );
3108    }
3109    Ok(out)
3110}
3111
3112/// Keep a comment only when it asks for something.
3113///
3114/// An inline comment always does: it names a file and a line. A top-level
3115/// comment is dropped when it is empty, when it is magi's own, or when it is
3116/// [noise](is_noise).
3117fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3118    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3119        return;
3120    }
3121    if comment.path.is_none() && is_noise(&comment.body) {
3122        return;
3123    }
3124    out.push(comment);
3125}
3126
3127/// Is this comment body machinery rather than a finding?
3128///
3129/// Two tests, both structural, because guessing from prose is how a "looks
3130/// good to me" turns into a fix round:
3131///
3132/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3133///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3134///    footer.
3135/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3136///    horizontal rules, and the bot's own status banner are removed, every
3137///    remaining line is a task-list item. That is exactly the shape of the
3138///    comment the Claude review job posts while it is still working.
3139///
3140/// Anything else is input, including bot prose. A bot that writes a paragraph
3141/// has said something, and the fix prompt tells the fixer it may decline a
3142/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3143/// missed finding.
3144pub fn is_noise(body: &str) -> bool {
3145    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3146        return true;
3147    }
3148    let mut content = false;
3149    for line in strip_blocks(body).lines() {
3150        let line = unquote(line);
3151        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3152            continue;
3153        }
3154        content = true;
3155        break;
3156    }
3157    !content
3158}
3159
3160/// Remove HTML comments and collapsed `<details>` blocks.
3161fn strip_blocks(body: &str) -> String {
3162    let mut out = String::with_capacity(body.len());
3163    let mut rest = body;
3164    loop {
3165        let open = ["<!--", "<details>"]
3166            .iter()
3167            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3168            .min_by_key(|(i, _)| *i);
3169        let Some((at, tag)) = open else {
3170            out.push_str(rest);
3171            return out;
3172        };
3173        out.push_str(&rest[..at]);
3174        let after = &rest[at + tag.len()..];
3175        let close = if tag == "<!--" { "-->" } else { "</details>" };
3176        match after.find(close) {
3177            Some(end) => rest = &after[end + close.len()..],
3178            // Unterminated: the rest of the body is inside the block.
3179            None => return out,
3180        }
3181    }
3182}
3183
3184/// Strip blockquote markers, which both bots wrap their callouts in.
3185fn unquote(line: &str) -> &str {
3186    let mut s = line.trim();
3187    while let Some(rest) = s.strip_prefix('>') {
3188        s = rest.trim_start();
3189    }
3190    s.trim()
3191}
3192
3193/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3194fn is_checklist(line: &str) -> bool {
3195    let rest = line
3196        .strip_prefix("- ")
3197        .or_else(|| line.strip_prefix("* "))
3198        .unwrap_or("");
3199    let rest = rest.trim_start();
3200    matches!(
3201        rest.get(..3),
3202        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3203    )
3204}
3205
3206/// A heading, a horizontal rule, or a callout tag - shape, never content.
3207fn is_decoration(line: &str) -> bool {
3208    line.starts_with('#')
3209        || line.starts_with("[!")
3210        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3211}
3212
3213/// A line that is nothing but emphasis and links.
3214///
3215/// Both review jobs open with a status banner
3216/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3217/// to a line-based test and asks for nothing, so it is measured the same way a
3218/// heading is: strip the markup, and if no word survives, it was decoration.
3219fn is_banner(line: &str) -> bool {
3220    let plain = drop_spans(line, "**", "**");
3221    let plain = if plain.contains("](") {
3222        drop_spans(&plain, "[", ")")
3223    } else {
3224        plain
3225    };
3226    !plain.chars().any(char::is_alphanumeric)
3227}
3228
3229/// Remove every `open` .. `close` span, including the delimiters. An
3230/// unterminated span swallows the rest of the input, which is what a reader
3231/// sees too.
3232fn drop_spans(s: &str, open: &str, close: &str) -> String {
3233    let mut out = String::with_capacity(s.len());
3234    let mut rest = s;
3235    while let Some(at) = rest.find(open) {
3236        out.push_str(&rest[..at]);
3237        let after = &rest[at + open.len()..];
3238        match after.find(close) {
3239            Some(end) => rest = &after[end + close.len()..],
3240            None => return out,
3241        }
3242    }
3243    out.push_str(rest);
3244    out
3245}
3246
3247/// The lock that keeps at most one run per repository actually moving the
3248/// base branch at a time: a rebase push, or `gh pr merge`.
3249///
3250/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3251/// running a fix round in the winner's own worktree, waiting on the owner's
3252/// approval - touches nothing a *different* run in the same repository could
3253/// collide with, and holding a lock across any of that would serialise one
3254/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3255/// resume, which is precisely the "must not wait on another task" property
3256/// the daemon's slot-freeing exists to give a resume. Only the two moments
3257/// that actually write to the shared base branch need mutual exclusion, and
3258/// both are brief.
3259///
3260/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3261/// different repositories' runs never wait on each other. The outer
3262/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3263/// insert an entry and clone its `Arc`, never across an `.await`.
3264fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3265    static LOCKS: std::sync::LazyLock<
3266        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3267    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3268    LOCKS
3269        .lock()
3270        .unwrap_or_else(std::sync::PoisonError::into_inner)
3271        .entry(repo.to_path_buf())
3272        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3273        .clone()
3274}
3275
3276/// `owner/repo` out of a pull request url, falling back to the checkout's
3277/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3278fn repo_label(repo: &Path, pr_url: &str) -> String {
3279    let parts: Vec<&str> = pr_url.split('/').collect();
3280    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3281        && at >= 2
3282        && !parts[at - 1].is_empty()
3283        && !parts[at - 2].is_empty()
3284    {
3285        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3286    }
3287    repo.file_name()
3288        .map(|n| n.to_string_lossy().into_owned())
3289        .unwrap_or_default()
3290}
3291
3292/// The operator-facing sentence for a merge that went ahead with red checks,
3293/// or `None` when the checks were not red. Judged on `checks`, not on
3294/// `failing`, which can be non-empty on a green observation.
3295fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3296    (pr.checks == Checks::Red).then(|| {
3297        let details = crate::notices::format_list(&pr.failing)
3298            .map(|names| format!(": {names}"))
3299            .unwrap_or_default();
3300        format!(
3301            "Merged {repo_name} PR #{} with red checks{details} ({})",
3302            pr.number, pr.url
3303        )
3304    })
3305}
3306
3307/// After a merge that succeeded: record which checks were red and tell the
3308/// operator. The decision to merge is already made; this only makes it audible.
3309/// Best-effort - a broken notifier never fails the run.
3310async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3311    let repo_name = repo_label(&state.repo, &pr.url);
3312    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3313        return;
3314    };
3315    if let Some(rec) = state.pr.as_mut() {
3316        rec.red_at_merge = pr.failing.clone();
3317    }
3318    state.event("land", summary.clone());
3319    // The notice pages through `[notify]` itself, once, unless a question
3320    // already carries the cause.
3321    crate::notices::raise_with(
3322        crate::notices::merged_red(&state.id, &summary),
3323        &state.config.notify,
3324    );
3325}
3326
3327/// Run the loop against a real pull request until it merges or the budget runs
3328/// out.
3329///
3330/// The caller decides whether landing happens at all: this is only reached when
3331/// `graph.land` is on. Returns the last observation, so the caller can report
3332/// what magi was looking at when it stopped.
3333pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3334    land_with(state, pr_url, &GhForge).await
3335}
3336
3337/// The forge calls whose timing the loop's decisions depend on, behind a seam
3338/// so a test can script what the pull request looks like from one observation
3339/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3340trait Forge {
3341    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3342        let (ok, out) = gh(
3343            repo,
3344            &[
3345                "pr".into(),
3346                "close".into(),
3347                pr_url.into(),
3348                "--comment".into(),
3349                comment.into(),
3350            ],
3351        )
3352        .await?;
3353        if !ok {
3354            bail!("PR closure failed: {out}");
3355        }
3356        Ok(())
3357    }
3358    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3359    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3360    async fn poll(&self);
3361    /// Rerun the failed jobs of one workflow run.
3362    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3363    /// Unix seconds, behind the seam so a test owns the clock.
3364    fn now(&self) -> i64;
3365    /// The check contexts the base branch requires, for a stop reason only.
3366    /// `None` when they could not be read, which is not the same as none.
3367    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3368    #[allow(clippy::too_many_arguments)]
3369    async fn fix(
3370        &self,
3371        state: &mut RunState,
3372        pr: &PrState,
3373        round: usize,
3374        budget: usize,
3375        reason: &str,
3376        logs: &str,
3377    ) -> Result<Fixed>;
3378}
3379
3380struct GhForge;
3381
3382impl Forge for GhForge {
3383    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3384        observe(repo, pr_url).await
3385    }
3386    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3387        gh(repo, argv).await
3388    }
3389    async fn poll(&self) {
3390        tokio::time::sleep(POLL).await;
3391    }
3392    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3393        let args = [
3394            "run".to_owned(),
3395            "rerun".to_owned(),
3396            run.to_owned(),
3397            "--failed".to_owned(),
3398        ];
3399        let (ok, out) = gh(repo, &args).await?;
3400        if !ok {
3401            bail!("gh run rerun {run}: {out}");
3402        }
3403        Ok(())
3404    }
3405    fn now(&self) -> i64 {
3406        jiff::Timestamp::now().as_second()
3407    }
3408    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3409        required_contexts_of(repo, base).await
3410    }
3411    async fn fix(
3412        &self,
3413        state: &mut RunState,
3414        pr: &PrState,
3415        round: usize,
3416        budget: usize,
3417        reason: &str,
3418        logs: &str,
3419    ) -> Result<Fixed> {
3420        fix_round(state, pr, round, budget, reason, logs).await
3421    }
3422}
3423
3424/// Percent-encode a branch name for a URL path segment (`/` included).
3425fn encode_path_segment(s: &str) -> String {
3426    let mut out = String::new();
3427    for b in s.bytes() {
3428        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3429            out.push(b as char);
3430        } else {
3431            let _ = write!(out, "%{b:02X}");
3432        }
3433    }
3434    out
3435}
3436
3437/// Read the required contexts of `base` from classic branch protection and
3438/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3439/// these two endpoints do not list are missed. Any unreadable source makes the
3440/// whole answer `None`: a partial set would read as a complete one.
3441async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3442    let enc = encode_path_segment(base);
3443    let mut all = BTreeSet::new();
3444    // Classic protection answers 404 for an unprotected branch, which is
3445    // "nothing required here", not a failure to read.
3446    let classic = gh(
3447        repo,
3448        &[
3449            "api".to_owned(),
3450            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3451        ],
3452    )
3453    .await
3454    .ok()?;
3455    if classic.0 {
3456        all.extend(parse_classic_required(&classic.1)?);
3457    } else if !classic.1.contains("404") {
3458        return None;
3459    }
3460    let rules = gh(
3461        repo,
3462        &[
3463            "api".to_owned(),
3464            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3465        ],
3466    )
3467    .await
3468    .ok()?;
3469    if !rules.0 {
3470        return None;
3471    }
3472    all.extend(parse_ruleset_required(&rules.1)?);
3473    Some(all)
3474}
3475
3476/// `required_status_checks` of classic protection: `contexts` plus the
3477/// `checks[].context` form.
3478fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3479    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3480    let mut out = BTreeSet::new();
3481    for c in v.get("contexts")?.as_array()? {
3482        out.insert(c.as_str()?.to_owned());
3483    }
3484    for c in v
3485        .get("checks")
3486        .and_then(|c| c.as_array())
3487        .into_iter()
3488        .flatten()
3489    {
3490        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3491            out.insert(name.to_owned());
3492        }
3493    }
3494    Some(out)
3495}
3496
3497/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3498fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3499    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3500    let mut out = BTreeSet::new();
3501    for rule in v.as_array()? {
3502        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3503            continue;
3504        }
3505        let checks = rule
3506            .pointer("/parameters/required_status_checks")?
3507            .as_array()?;
3508        for c in checks {
3509            out.insert(c.get("context")?.as_str()?.to_owned());
3510        }
3511    }
3512    Some(out)
3513}
3514
3515/// Is the observation older than the commit a fix round pushed?
3516///
3517/// The forge takes a few seconds to move the pull request to a new head and
3518/// attach that head's check runs, and until it has, the rollup is the previous
3519/// head's - all green, which is exactly what a merge decision must not read.
3520/// An absent or different head counts as not yet: guessing "close enough"
3521/// would reopen the hole.
3522fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3523    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3524}
3525
3526/// The commit an observation may be decided on, or `None` while it cannot be
3527/// trusted to describe one.
3528///
3529/// `None` when a pushed commit is awaited and the pull request is not on it,
3530/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3531/// last commit's) is not the head's: that is the previous commit's checks. The
3532/// caller re-polls on `None`. A rollup that cannot be read (including one
3533/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3534/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3535fn bound_head<'a>(
3536    seen_head: &'a str,
3537    rollup_head: &str,
3538    awaiting: Option<&str>,
3539) -> Option<&'a str> {
3540    if seen_head.is_empty()
3541        || awaiting_new_head(awaiting, seen_head)
3542        || !rollup_head.eq_ignore_ascii_case(seen_head)
3543    {
3544        return None;
3545    }
3546    Some(seen_head)
3547}
3548
3549/// What a refused `gh pr merge` means.
3550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3551enum Refused {
3552    /// The branch policy is not satisfied *yet*: go back to waiting.
3553    Pending,
3554    /// Checks have settled and the merge state still says no, seen for the
3555    /// first time. The forge updates `mergeStateStatus` a moment after the last
3556    /// check finishes, so one more look is allowed before believing it.
3557    Recheck,
3558    /// Nothing is in flight and the policy still refuses: a person has to
3559    /// supply what it asks for (a review, say).
3560    Final,
3561}
3562
3563/// Judged from the pull request's state after the refusal, never from the
3564/// refusal's wording, which belongs to the forge and changes.
3565fn classify_refusal(after: Option<&Seen>, rechecked: bool, observed_head: &str) -> Refused {
3566    let Some(after) = after else {
3567        // Unreadable is not evidence of anything; the next loop reads again.
3568        return Refused::Pending;
3569    };
3570    if after.pr.state != PrLifecycle::Open {
3571        return Refused::Final;
3572    }
3573    // The branch moved after it was observed (the forge refuses a merge pinned
3574    // to the old commit): not a verdict on anything, look again.
3575    if !after.head.eq_ignore_ascii_case(observed_head) {
3576        return Refused::Pending;
3577    }
3578    // The same binding the loop applies: checks of another commit say nothing.
3579    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3580        return Refused::Pending;
3581    }
3582    let state = after.merge_state.to_ascii_uppercase();
3583    if matches!(after.pr.checks, Checks::Pending | Checks::Unknown)
3584        || state.is_empty()
3585        || state == "UNKNOWN"
3586    {
3587        return Refused::Pending;
3588    }
3589    if rechecked {
3590        Refused::Final
3591    } else {
3592        Refused::Recheck
3593    }
3594}
3595
3596/// Take auto-merge back from the forge and forget that it was armed.
3597///
3598/// `Err` carries the forge's message: the caller must not push or move on, as
3599/// an armed merge left behind could still fire for a commit nobody approved.
3600async fn disarm<F: Forge>(
3601    forge: &F,
3602    state: &mut RunState,
3603    repo: &Path,
3604    number: u64,
3605) -> std::result::Result<(), String> {
3606    let argv = disable_automerge_argv(number);
3607    let out = {
3608        let merge_lock = repo_merge_lock(repo);
3609        let _merge_slot = merge_lock.lock().await;
3610        forge.merge(repo, &argv).await
3611    };
3612    match out {
3613        Ok((true, _)) => {
3614            state.land_armed_head = None;
3615            state.event("land", "auto-merge disabled");
3616            state.save().map_err(|e| format!("{e:#}"))?;
3617            Ok(())
3618        }
3619        Ok((false, msg)) => Err(msg),
3620        Err(e) => Err(format!("{e:#}")),
3621    }
3622}
3623
3624/// [`stop`], first taking back an armed auto-merge so a pull request magi
3625/// gave up on does not merge on its own later. A failure to disarm is added
3626/// to the reason rather than hiding it.
3627async fn stop_disarmed<F: Forge>(
3628    forge: &F,
3629    state: &mut RunState,
3630    repo: &Path,
3631    pr: &PrState,
3632    why: &str,
3633) -> Result<()> {
3634    if state.land_armed_head.is_none() {
3635        return stop(state, repo, pr, why).await;
3636    }
3637    match disarm(forge, state, repo, pr.number).await {
3638        Ok(()) => stop(state, repo, pr, why).await,
3639        Err(e) => {
3640            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3641            stop(state, repo, pr, &why).await
3642        }
3643    }
3644}
3645
3646/// Check the persisted card again around awaits that can outlive an owner tap.
3647fn owner_requested_stop(state: &RunState) -> Result<bool> {
3648    let Some(binding) = &state.land_approval else {
3649        return Ok(false);
3650    };
3651    let q = ask::Questions::open().get(&binding.question)?;
3652    Ok(q.run == state.id
3653        && q.node == APPROVAL_NODE
3654        && q.resolution()
3655            .as_deref()
3656            .is_some_and(crate::merge_decision::stops))
3657}
3658
3659/// Read stop choices before checks, repair or rebase. These are about stopping
3660/// ownership, so a changed head never cancels them as it cancels merge consent.
3661async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3662    use crate::merge_decision as md;
3663    let Some(binding) = &state.land_approval else {
3664        return Ok(false);
3665    };
3666    let q = ask::Questions::open().get(&binding.question)?;
3667    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3668        return Ok(false);
3669    };
3670    if q.run != state.id || q.node != APPROVAL_NODE {
3671        bail!("stop decision names another run");
3672    }
3673    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3674        pr: pr.url.clone(),
3675        head: binding.head.clone(),
3676        language: state.config.graph.language.clone(),
3677    });
3678    if target.pr != pr.url {
3679        bail!("stop decision names another PR");
3680    }
3681    if state.merge_decision.is_none() {
3682        state.merge_decision = Some(md::Decision {
3683            question: q.id.clone(),
3684            target,
3685            choice: choice.clone(),
3686            closed: false,
3687            replacement_run: None,
3688            replacement_pr: None,
3689        });
3690        state.save()?;
3691    }
3692    let repo = state.repo.clone();
3693    // Always disable, including arms left by an older driver.
3694    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3695        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3696        return Ok(true);
3697    }
3698    if choice.eq_ignore_ascii_case(HOLD) {
3699        stop(
3700            state,
3701            &repo,
3702            pr,
3703            "owner held the decision: PR left open; no further repair or merge work",
3704        )
3705        .await?;
3706        return Ok(true);
3707    }
3708    if md::selected_agent(&choice).is_some()
3709        || state
3710            .origin
3711            .as_ref()
3712            .and_then(|o| o.task.as_ref())
3713            .is_some()
3714    {
3715        let owners: Vec<_> = crate::queue::Queue::open()
3716            .list()
3717            .into_iter()
3718            .filter(|task| task.runs.last() == Some(&state.id))
3719            .collect();
3720        let owner = (owners.len() == 1).then(|| &owners[0]);
3721        if !owner.is_some_and(|task| {
3722            task.runs.last() == Some(&state.id)
3723                && (!task
3724                    .operator_decided_at
3725                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3726                    || task.resumption.as_ref().is_some_and(|release| {
3727                        release.question_id.as_deref() == Some(q.id.as_str())
3728                            && task.operator_decided_at == Some(release.at)
3729                    }))
3730        }) {
3731            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3732            return Ok(true);
3733        }
3734    }
3735    if pr.state == PrLifecycle::Merged {
3736        stop(
3737            state,
3738            &repo,
3739            pr,
3740            "PR already merged; withdrawal/replacement cannot be applied",
3741        )
3742        .await?;
3743        return Ok(true);
3744    }
3745    let comment = format!(
3746        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3747        q.id, choice, state.id
3748    );
3749    if pr.state == PrLifecycle::Open {
3750        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3751            stop(
3752                state,
3753                &repo,
3754                pr,
3755                &format!(
3756                    "owner requested closure but it failed: {e}; task held, no replacement started"
3757                ),
3758            )
3759            .await?;
3760            return Ok(true);
3761        }
3762    }
3763    let confirmed = forge.view(&repo, &pr.url).await?;
3764    if confirmed.pr.state != PrLifecycle::Closed {
3765        stop(
3766            state,
3767            &repo,
3768            pr,
3769            "PR closure was not confirmed; task held, no replacement started",
3770        )
3771        .await?;
3772        return Ok(true);
3773    }
3774    *pr = confirmed.pr;
3775    state.merge_decision.as_mut().unwrap().closed = true;
3776    if let Some(record) = &mut state.pr {
3777        record.state = "closed".into();
3778    }
3779    state.status = RunStatus::Superseded;
3780    state.merge = Some(MergeOutcome {
3781        mode: MergeMode::Pr,
3782        ok: false,
3783        detail: format!("owner decision: {choice}; PR closure confirmed"),
3784        empty: false,
3785    });
3786    state.event(
3787        "land",
3788        format!(
3789            "{} applied: PR closure confirmed; old execution retired",
3790            choice
3791        ),
3792    );
3793    state.save()?;
3794    Ok(true)
3795}
3796
3797/// Keep an unstarted verification resumable without charging a task attempt.
3798fn check_landing_resources(
3799    state: &mut RunState,
3800    node: &str,
3801    outcomes: &[crate::run::CommandOutcome],
3802) -> Result<()> {
3803    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3804        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3805        state.resource_block = Some(crate::run::ResourceBlock {
3806            node: node.into(),
3807            kind: "shared build cache".into(),
3808            detail: detail.clone(),
3809            at: jiff::Timestamp::now(),
3810        });
3811        state.event("resource", &detail);
3812        state.save()?;
3813        bail!("landing verification could not start: {detail}");
3814    }
3815    Ok(())
3816}
3817
3818/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3819/// A push completed before a crash is recoverable only when its SHA was gated.
3820async fn publish_landing_head<F: Forge>(
3821    state: &mut RunState,
3822    observed: &str,
3823    pr_url: &str,
3824    forge: &F,
3825) -> Result<String> {
3826    crate::pr_head::ensure_owner(state)?;
3827    let winner = state.winner().cloned().context("landing has no winner")?;
3828    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3829    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3830    anyhow::ensure!(
3831        observed == expected
3832            || (observed == head
3833                && state.gate_head.as_deref() == Some(&head)
3834                && state.gate_allows_progress()),
3835        "public head moved outside this run; refusing to overwrite it"
3836    );
3837    anyhow::ensure!(
3838        git::is_clean(&winner.worktree).await?,
3839        "landing worktree is dirty"
3840    );
3841    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3842    {
3843        state.gate.clear();
3844        state.gate_head = None;
3845        state.gate_ran = false;
3846        state.pre_gate.clear();
3847        state.pre_gate_commit = None;
3848        state.save()?;
3849        let inherited = crate::repo_lock::WaitPolicy::current();
3850        let wait = crate::repo_lock::WaitPolicy::new(
3851            Duration::from_secs(state.config.graph.timeout_resource_wait),
3852            move || inherited.cancelled(),
3853        );
3854        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3855        loop {
3856            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3857                state.gate = outcomes.clone();
3858                check_landing_resources(state, "gate", &outcomes)?;
3859            }
3860            if outcomes.iter().all(|o| o.ok()) {
3861                break;
3862            }
3863            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3864                crate::graph::GateFix::Retry => {
3865                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3866                }
3867                crate::graph::GateFix::Stop => break,
3868                crate::graph::GateFix::Defer => {
3869                    // The repair checks e2e immediately, before the next gate.
3870                    // Preserve a cache wait here as well as below; otherwise
3871                    // the daemon charges an attempt for unstarted verification.
3872                    if let Some(e2e) = &state.gate_repair_e2e {
3873                        let outcomes = e2e.outcomes.clone();
3874                        check_landing_resources(state, "e2e", &outcomes)?;
3875                    }
3876                    bail!("landing gate repair deferred");
3877                }
3878            }
3879        }
3880        state.gate = outcomes;
3881        state.gate_ran = true;
3882        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3883        state.save()?;
3884    }
3885    anyhow::ensure!(
3886        state.gate_allows_progress(),
3887        "landing gate failed; nothing published"
3888    );
3889    let checked = state
3890        .gate_head
3891        .clone()
3892        .context("landing gate has no checked SHA")?;
3893    anyhow::ensure!(
3894        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3895            && git::is_clean(&winner.worktree).await?,
3896        "landing tree changed after gate"
3897    );
3898    let inherited = crate::repo_lock::WaitPolicy::current();
3899    let wait = crate::repo_lock::WaitPolicy::new(
3900        Duration::from_secs(state.config.graph.timeout_resource_wait),
3901        move || inherited.cancelled(),
3902    );
3903    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3904    if matches!(e2e, crate::graph::GateFix::Defer) {
3905        let outcomes = state
3906            .gate_repair_e2e
3907            .as_ref()
3908            .context("deferred landing e2e has no outcomes")?
3909            .outcomes
3910            .clone();
3911        check_landing_resources(state, "e2e", &outcomes)?;
3912    }
3913    anyhow::ensure!(
3914        matches!(e2e, crate::graph::GateFix::Retry),
3915        "post-repair e2e has not passed on the landing head; nothing published"
3916    );
3917    anyhow::ensure!(
3918        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3919            && git::is_clean(&winner.worktree).await?,
3920        "landing tree changed during post-repair e2e"
3921    );
3922    crate::pr_head::ensure_owner(state)?;
3923    if owner_requested_stop(state)? {
3924        bail!("owner requested stop before publication");
3925    }
3926    let current = forge.view(&state.repo, pr_url).await?;
3927    anyhow::ensure!(
3928        current.pr.state == PrLifecycle::Open,
3929        "PR closed or merged while gating; nothing published"
3930    );
3931    anyhow::ensure!(
3932        current.head == observed || current.head == checked,
3933        "PR head changed while gating; nothing published"
3934    );
3935    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3936    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3937    let refspec = format!("{checked}:refs/heads/{public}");
3938    let lock = repo_merge_lock(&state.repo);
3939    let _slot = lock.lock().await;
3940    let pushed = git::git_raw(
3941        &winner.worktree,
3942        &["push", &lease, &state.config.merge.remote, &refspec],
3943    )
3944    .await?;
3945    anyhow::ensure!(
3946        pushed.ok(),
3947        "publishing gated landing head failed: {}",
3948        pushed.stderr
3949    );
3950    state.pr_pushed_head = Some(checked.clone());
3951    state.land_sync_pending = false;
3952    state.save()?;
3953    Ok(checked)
3954}
3955
3956/// A scratch rebase moves the winner ref before its index and files. Recover
3957/// only a tree proven unchanged since the saved start; never discard edits.
3958async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3959    let winner = state.winner().cloned().context("landing has no winner")?;
3960    let inherited = crate::repo_lock::WaitPolicy::current();
3961    let wait = crate::repo_lock::WaitPolicy::new(
3962        Duration::from_secs(state.config.graph.timeout_resource_wait),
3963        move || inherited.cancelled(),
3964    );
3965    let _coordination = crate::repo_lock::SeatLease::operation_with(
3966        &winner.worktree,
3967        state.config.coordination.as_ref(),
3968        &wait,
3969    )
3970    .await?;
3971    if !git::is_clean(&winner.worktree).await? {
3972        let from = state
3973            .land_rebase_from
3974            .as_deref()
3975            .context("landing worktree is dirty")?;
3976        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3977        anyhow::ensure!(head != from, "landing worktree is dirty");
3978        for args in [
3979            vec!["diff", "--cached", "--quiet", from],
3980            vec!["diff", "--quiet"],
3981        ] {
3982            anyhow::ensure!(
3983                git::git_raw(&winner.worktree, &args).await?.ok(),
3984                "landing worktree has edits beyond the interrupted rebase"
3985            );
3986        }
3987        // Preserve build artifacts and local files, including ignored ones.
3988        // read-tree can overwrite ignored paths, so reject collisions with
3989        // the new tree (including file/directory conflicts) before updating.
3990        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
3991        let tracked = git::git(
3992            &winner.worktree,
3993            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
3994        )
3995        .await?;
3996        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
3997            anyhow::ensure!(
3998                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
3999                    path == local
4000                        || path
4001                            .strip_prefix(local)
4002                            .is_some_and(|rest| rest.starts_with('/'))
4003                        || local
4004                            .strip_prefix(path)
4005                            .is_some_and(|rest| rest.starts_with('/'))
4006                }),
4007                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
4008            );
4009        }
4010        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
4011    }
4012    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
4013    state.land_rebase_from = None;
4014    state.save()?;
4015    Ok(())
4016}
4017
4018async fn rebase_landing_worktree(
4019    state: &mut RunState,
4020    onto: &str,
4021) -> Result<crate::rebase::Rebased> {
4022    restore_landing_worktree(state).await?;
4023    let winner = state.winner().cloned().context("landing has no winner")?;
4024    state.land_sync_pending = true;
4025    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
4026    state.save()?;
4027    let scratch = state.dir().join("rebase");
4028    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
4029    if result == crate::rebase::Rebased::Applied {
4030        restore_landing_worktree(state).await?;
4031    }
4032    Ok(result)
4033}
4034
4035/// Reconcile a stopped landing before changing its branch. This also handles
4036/// old records whose graph status or merge detail no longer identifies land.
4037async fn resume_landing_head<F: Forge>(
4038    state: &mut RunState,
4039    observed: &str,
4040    pr_url: &str,
4041    forge: &F,
4042) -> Result<String> {
4043    crate::pr_head::ensure_owner(state)?;
4044    let winner = state.winner().cloned().context("landing has no winner")?;
4045    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4046    let expected = state
4047        .pr_pushed_head
4048        .as_deref()
4049        .or(state.gate_head.as_deref())
4050        .context("missing public head lease")?;
4051    anyhow::ensure!(
4052        observed == expected
4053            || (observed == local
4054                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4055                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4056        "public head moved outside this run; refusing to sync"
4057    );
4058    // An operator may have extended and published the winner while approval
4059    // was pending. Matching local/public history can be adopted as the lease,
4060    // but publish_landing_head must gate its new SHA before proceeding. A
4061    // remote-only or divergent update still cannot be overwritten.
4062    // Save reconciliation before rebasing again; a prior push may have reached
4063    // the remote immediately before the previous driver's last save.
4064    state.pr_pushed_head = Some(observed.to_owned());
4065    state.save()?;
4066    restore_landing_worktree(state).await?;
4067    let remote = state.config.merge.remote.clone();
4068    let base = state.base_branch.clone();
4069    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4070    anyhow::ensure!(
4071        fetched.ok(),
4072        "cannot fetch landing base: {}",
4073        fetched.stderr
4074    );
4075    let onto = format!("{remote}/{base}");
4076    let tip = git::rev_parse(&state.repo, &onto).await?;
4077    if !git::is_ancestor(&state.repo, &tip, &local).await {
4078        match rebase_landing_worktree(state, &onto).await? {
4079            crate::rebase::Rebased::Applied => {}
4080            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4081        }
4082    }
4083    publish_landing_head(state, observed, pr_url, forge).await
4084}
4085
4086async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4087    crate::pr_head::ensure_owner(state)?;
4088    let reopening_hold = crate::merge_decision::resumable_hold(state);
4089    let repo = state.repo.clone();
4090    let budget = state.config.graph.land_rounds;
4091    let mut round = 0usize;
4092    // Counted apart from `round`: a rebase is not a fix, and a base that
4093    // moved is not the change's fault.
4094    let mut rebases = 0usize;
4095    let mut waited = Duration::ZERO;
4096    // Comment bodies the fixer has already been shown. A comment is
4097    // outstanding until it has been handed over once; after that it is a
4098    // recorded decision, not an open question, and re-feeding it would loop the
4099    // budget away on a comment the fixer already declined with an argument.
4100    let mut shown: BTreeSet<String> = BTreeSet::new();
4101    // Await the last successfully published head, including across resumes.
4102    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4103    // Whether a settled-checks refusal has already been given its one re-look.
4104    let mut rechecked = false;
4105
4106    // Marks the run resumable through exactly this function, not through a
4107    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4108    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4109    // status specifically to know a resumed run belongs back in `land`
4110    // rather than at a second `gh pr create`. Set on every entry - fresh or
4111    // resumed - because a resume that parked here again must keep reading
4112    // `Landing`, not whatever a first pass through `merge` left behind.
4113    state.status = RunStatus::Landing;
4114    state.event("land", format!("watching {pr_url}"));
4115    state.save()?;
4116
4117    // An armed merge recorded by an earlier pass may or may not have reached
4118    // the forge (the record is written before the call). It is taken back on
4119    // the first observation, where a pull request is known to stop with.
4120    let mut resumed_armed = state.land_armed_head.is_some();
4121
4122    loop {
4123        let seen = forge.view(&repo, pr_url).await?;
4124        let mut pr = seen.pr.clone();
4125        pr.review_comments.retain(|c| !shown.contains(&c.body));
4126        state.pr = Some(crate::run::PrRecord {
4127            url: pr.url.clone(),
4128            number: pr.number,
4129            state: pr.state.as_str().to_owned(),
4130            checks: pr.checks.as_str().to_owned(),
4131            round,
4132            rounds: budget,
4133            red_at_merge: Vec::new(),
4134        });
4135        state.save()?;
4136
4137        crate::pr_head::ensure_owner(state)?;
4138        if reopening_hold {
4139            anyhow::ensure!(
4140                pr.state == PrLifecycle::Open,
4141                "held PR is no longer open; cannot request a fresh decision"
4142            );
4143            // An explicit resume authorizes a fresh question only. Preserve the
4144            // answered card, and never enter repair/rebase/merge on this pass.
4145            disarm(forge, state, &repo, pr.number)
4146                .await
4147                .map_err(anyhow::Error::msg)?;
4148            let previous = state.merge_decision.take();
4149            let binding = state.land_approval.take();
4150            state.config.graph.land_approval = true;
4151            let subject = merge_subject(
4152                crate::graph::landing_title(state, &seen.title),
4153                &crate::graph::landing_subject_source(state),
4154            );
4155            let contested = contested_to_ask(state);
4156            if let Err(error) =
4157                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4158            {
4159                state.merge_decision = previous;
4160                state.land_approval = binding;
4161                return Err(error);
4162            }
4163            state.end_pass_parked(crate::run::ParkCause::Question);
4164            state.parked = true;
4165            state.event("land", format!("explicit resume reopened held decision {}; parked awaiting a fresh owner answer", previous.as_ref().map(|d| d.question.as_str()).unwrap_or_default()));
4166            state.save()?;
4167            return Ok(pr);
4168        }
4169        if owner_stop(state, &mut pr, forge).await? {
4170            return Ok(pr);
4171        }
4172
4173        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4174            // An approval already given for the same head is reused, so
4175            // nothing is asked twice. A failed disable
4176            // stops the run with the record kept: pushing on could change the
4177            // head under an arm that is still live.
4178            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4179                let why = format!(
4180                    "a previous pass may have armed auto-merge and it could not be disabled \
4181                     on resume: {e}"
4182                );
4183                stop(state, &repo, &pr, &why).await?;
4184                return Ok(pr);
4185            }
4186        }
4187
4188        // The head moved away from the one auto-merge was armed on, by
4189        // someone other than this loop. The arm is pinned and would refuse to
4190        // merge the new commit, but the approval was for the old one: take it
4191        // back and go through the normal path again.
4192        if pr.state == PrLifecycle::Open
4193            && !seen.head.is_empty()
4194            && state
4195                .land_armed_head
4196                .as_deref()
4197                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4198        {
4199            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4200                let why = format!(
4201                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4202                );
4203                stop(state, &repo, &pr, &why).await?;
4204                return Ok(pr);
4205            }
4206        }
4207
4208        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4209            // Disarming can outlive an owner answer. Consume it before our
4210            // own rebase invalidates the head shown on the decision card.
4211            if owner_stop(state, &mut pr, forge).await? {
4212                return Ok(pr);
4213            }
4214            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4215                binding.head.eq_ignore_ascii_case(&seen.head)
4216                    && ask::Questions::open()
4217                        .get(&binding.question)
4218                        .is_ok_and(|q| {
4219                            q.run == state.id
4220                                && q.node == APPROVAL_NODE
4221                                && q.status == ask::QuestionStatus::Answered
4222                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4223                        })
4224            });
4225            if repair_answer {
4226                let subject = merge_subject(
4227                    crate::graph::landing_title(state, &seen.title),
4228                    &crate::graph::landing_subject_source(state),
4229                );
4230                let contested = contested_to_ask(state);
4231                if matches!(
4232                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4233                    ApprovalGate::Repair
4234                ) {
4235                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4236                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4237                        state.save()?;
4238                    }
4239                    return Ok(pr);
4240                }
4241            }
4242            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4243            // A stop detected before publication must apply its durable
4244            // decision, even when verification returned an error meanwhile.
4245            if owner_stop(state, &mut pr, forge).await? {
4246                return Ok(pr);
4247            }
4248            let head = synced?;
4249            awaiting_head = Some(head);
4250            waited = Duration::ZERO;
4251            rechecked = false;
4252            // Read CI and approvals afresh, never from the pre-push observation.
4253            continue;
4254        }
4255
4256        if pr.state == PrLifecycle::Open {
4257            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4258                if waited >= WAIT_CEILING {
4259                    let want = awaiting_head.as_deref().unwrap_or_default();
4260                    let why = format!(
4261                        "the pull request's checks were still not about one readable head after \
4262                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4263                         someone may have pushed over it",
4264                        WAIT_CEILING.as_secs() / 60,
4265                        if want.is_empty() { "any" } else { want },
4266                        if seen.head.is_empty() {
4267                            "nothing readable"
4268                        } else {
4269                            &seen.head
4270                        },
4271                        if seen.rollup_head.is_empty() {
4272                            "nothing readable"
4273                        } else {
4274                            &seen.rollup_head
4275                        },
4276                    );
4277                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4278                    return Ok(pr);
4279                }
4280                waited += POLL;
4281                forge.poll().await;
4282                continue;
4283            }
4284            // Reset once, when the awaited head first shows up; resetting on
4285            // every re-observation would let a standing refusal wait forever.
4286            if awaiting_head.take().is_some() {
4287                // The checks now being read belong to the new head; give them
4288                // the same grace a fresh pull request gets.
4289                waited = Duration::ZERO;
4290            }
4291        }
4292
4293        // A failed workflow run is rerun once, judged by its own run rather
4294        // than by the rollup's aggregate: another run still pending, or a fix
4295        // budget already spent, must not keep a flaky failure from its one
4296        // rerun. It spends no round and no attempt.
4297        if pr.state == PrLifecycle::Open && state.config.graph.rerun_flaky_checks {
4298            let failing = rerunnable(&seen.contexts);
4299            let now = forge.now();
4300            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4301                RerunStep::Rerun(runs) => {
4302                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4303                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4304                        *rec = LandReruns {
4305                            head: seen.head.clone(),
4306                            runs: BTreeMap::new(),
4307                        };
4308                    }
4309                    for run in &runs {
4310                        rec.runs.insert(run.clone(), now);
4311                    }
4312                    // Durable before the call: a restart must not
4313                    // rerun the same run twice. A save that fails
4314                    // means no rerun.
4315                    state.save()?;
4316                    let mut any = false;
4317                    for run in &runs {
4318                        let names = failing
4319                            .iter()
4320                            .filter(|(_, r)| r.as_deref() == Some(run))
4321                            .map(|(l, _)| l.as_str())
4322                            .collect::<Vec<_>>()
4323                            .join(", ");
4324                        match forge.rerun(&repo, run).await {
4325                            Ok(()) => {
4326                                any = true;
4327                                state.event(
4328                                    "land",
4329                                    format!("reran failed check(s) {names} (run {run})"),
4330                                );
4331                            }
4332                            // Stays recorded: never a retry loop.
4333                            Err(e) => state.event(
4334                                "land",
4335                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4336                            ),
4337                        }
4338                    }
4339                    state.save()?;
4340                    if any {
4341                        waited = Duration::ZERO;
4342                        forge.poll().await;
4343                        continue;
4344                    }
4345                }
4346                RerunStep::Grace => {
4347                    if waited >= WAIT_CEILING {
4348                        let why = format!(
4349                            "checks were still failing after a rerun and {} minutes",
4350                            WAIT_CEILING.as_secs() / 60
4351                        );
4352                        stop(state, &repo, &pr, &why).await?;
4353                        return Ok(pr);
4354                    }
4355                    waited += POLL;
4356                    forge.poll().await;
4357                    continue;
4358                }
4359                RerunStep::Fix => {}
4360            }
4361        }
4362
4363        let step = decide(&pr, round, budget, waited);
4364        // Armed on exactly this head: the forge is doing the waiting. A
4365        // decision to merge (or keep waiting) is only watched, never re-armed
4366        // and never re-approved; anything else - a red check, a comment, a
4367        // conflict - falls through to its own arm, which disarms first.
4368        let armed_here = state
4369            .land_armed_head
4370            .as_deref()
4371            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4372        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4373            if waited >= WAIT_CEILING {
4374                let required = if seen.base.is_empty() {
4375                    None
4376                } else {
4377                    forge.required_contexts(&repo, &seen.base).await
4378                };
4379                let why = format!(
4380                    "auto-merge was armed on {} but the pull request did not merge within {} \
4381                     minutes ({})",
4382                    seen.head,
4383                    WAIT_CEILING.as_secs() / 60,
4384                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4385                );
4386                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4387                return Ok(pr);
4388            }
4389            waited += POLL;
4390            forge.poll().await;
4391            continue;
4392        }
4393        if armed_here && !matches!(step, Step::Done { .. }) {
4394            // Not merging or waiting any more: whatever is next may change the
4395            // head or give up, and neither may leave the arm standing.
4396            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4397                let why = format!("auto-merge could not be disabled: {e}");
4398                stop(state, &repo, &pr, &why).await?;
4399                return Ok(pr);
4400            }
4401        }
4402        match step {
4403            Step::Wait => {
4404                if waited >= WAIT_CEILING {
4405                    let why = format!(
4406                        "checks were still running after {} minutes",
4407                        WAIT_CEILING.as_secs() / 60
4408                    );
4409                    stop(state, &repo, &pr, &why).await?;
4410                    return Ok(pr);
4411                }
4412                waited += POLL;
4413                forge.poll().await;
4414            }
4415            Step::Done { merged } => {
4416                if merged {
4417                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4418                }
4419                // Auto-merge is pinned to the approved head, but the forge's
4420                // own handling of a later push is not something magi can
4421                // see: if the pull request merged on another commit, say so
4422                // loudly rather than record a clean landing.
4423                if let Some(armed) = state.land_armed_head.take() {
4424                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4425                        let msg = format!(
4426                            "{} merged on {} but the owner approved {armed}; review what landed",
4427                            pr.url, seen.head
4428                        );
4429                        tracing::warn!("{msg}");
4430                        state.event("land", msg);
4431                        // Only an arm left by an older build can get here.
4432                        // The wording is fixed so a repeat does not relight
4433                        // the notice.
4434                        crate::notices::raise_with(
4435                            crate::notices::Notice::warn(
4436                                &format!("merged-unapproved-head:{}", state.id),
4437                                "A pull request merged on a commit the owner did not approve; \
4438                                 review what landed",
4439                            )
4440                            .link(crate::notices::Link::Run {
4441                                id: state.id.clone(),
4442                            }),
4443                            &state.config.notify,
4444                        );
4445                    }
4446                }
4447                state.status = if merged {
4448                    RunStatus::Merged
4449                } else {
4450                    RunStatus::Ready
4451                };
4452                let detail = if merged {
4453                    format!("{} was merged", pr.url)
4454                } else {
4455                    format!("{} was closed without merging", pr.url)
4456                };
4457                state.merge = Some(MergeOutcome {
4458                    mode: MergeMode::Pr,
4459                    ok: merged,
4460                    detail: detail.clone(),
4461                    empty: false,
4462                });
4463                state.event("land", detail);
4464                state.save()?;
4465                write_pr_state_through(state, pr.state);
4466                return Ok(pr);
4467            }
4468            Step::Merge => {
4469                state.check_delivery_evidence(&seen.head)?;
4470                let subject = merge_subject(
4471                    crate::graph::landing_title(state, &seen.title),
4472                    &crate::graph::landing_subject_source(state),
4473                );
4474                // The owner sees the panel before the one irreversible step,
4475                // and an unanswered question is a hold: silence never merges.
4476                //
4477                // The dedicated policy includes existing consent and post-repair
4478                // decisions; repair choice availability never grants merge authority.
4479                let contested = contested_to_ask(state);
4480                if !approval_reasons(state).is_empty() {
4481                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4482                        .await?
4483                    {
4484                        ApprovalGate::Approved => {}
4485                        ApprovalGate::Repair => {
4486                            // The driver consumes the saved request under the existing
4487                            // run/task claim. No merge, no second run or task.
4488                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4489                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4490                                state.save()?;
4491                            }
4492                            return Ok(pr);
4493                        }
4494                        ApprovalGate::Held => {
4495                            // Another entry point may have saved a stop after
4496                            // the first read above. Apply its durable action.
4497                            if owner_stop(state, &mut pr, forge).await? {
4498                                return Ok(pr);
4499                            }
4500                            stop(
4501                                state,
4502                                &repo,
4503                                &pr,
4504                                "the owner did not approve the merge (held or unanswered)",
4505                            )
4506                            .await?;
4507                            return Ok(pr);
4508                        }
4509                        // Filed (or still standing from an earlier visit) and
4510                        // not yet answered. Park here rather than wait: the
4511                        // question survives on disk, the daemon hands this
4512                        // run's slot to something else, and a later resume
4513                        // re-enters `land`, finds the same question, and
4514                        // either merges or stops depending on what it says
4515                        // by then.
4516                        ApprovalGate::Pending => {
4517                            state.end_pass_parked(crate::run::ParkCause::Question);
4518                            state.parked = true;
4519                            state.event(
4520                                "land",
4521                                "parked awaiting merge approval - resumes once answered",
4522                            );
4523                            state.save()?;
4524                            return Ok(pr);
4525                        }
4526                    }
4527                }
4528                // Open and past the gate above, so `seen.head` is the commit
4529                // the checks were bound to and the owner approved.
4530                //
4531                // Merge directly, bound to that commit. Auto-merge is not
4532                // armed any more: the forge checks `--match-head-commit` only
4533                // when the request is made, so an arm outlives the head it
4534                // was made for, and a push of another commit whose
4535                // requirements are met first would merge without approval.
4536                // A direct merge is checked by the forge at the moment it
4537                // happens, so only the approved head can land.
4538                let observed_head = seen.head.clone();
4539                // Read the pull request again just before: the state seen
4540                // above can be a moment old.
4541                {
4542                    let fresh = forge.view(&repo, pr_url).await.ok();
4543                    if !direct_merge_is_safe(
4544                        fresh.as_ref(),
4545                        &observed_head,
4546                        &shown,
4547                        round,
4548                        budget,
4549                        waited,
4550                    ) {
4551                        if waited >= WAIT_CEILING {
4552                            let why = "the pull request did not settle on the approved head \
4553                                       before it could be merged";
4554                            stop(state, &repo, &pr, why).await?;
4555                            return Ok(pr);
4556                        }
4557                        state.event(
4558                            "land",
4559                            "the pull request changed before merging; looking again",
4560                        );
4561                        waited += POLL;
4562                        forge.poll().await;
4563                        continue;
4564                    }
4565                }
4566                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4567                let out = {
4568                    let merge_lock = repo_merge_lock(&repo);
4569                    let _merge_slot = merge_lock.lock().await;
4570                    crate::pr_head::ensure_owner(state)?;
4571                    if let Some(task_id) = state
4572                        .origin
4573                        .as_ref()
4574                        .and_then(|origin| origin.task.as_ref())
4575                    {
4576                        let task = crate::queue::Queue::open()
4577                            .get(task_id)
4578                            .context("cannot verify the task owner immediately before merge")?;
4579                        if task.runs.last() != Some(&state.id)
4580                            || task.operator_held()
4581                            || task.status == crate::queue::TaskStatus::Done
4582                        {
4583                            bail!(
4584                                "task ownership or an operator hold superseded this merge approval"
4585                            );
4586                        }
4587                    }
4588                    if let Some(binding) = &state.land_approval {
4589                        let current = ask::Questions::open().get(&binding.question)?;
4590                        if merge_consent_superseded(state, &current)?
4591                            || current.run != state.id
4592                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4593                            || approval(current.resolution().as_deref()) != Approval::Merge
4594                            || current.merge_target.as_ref().is_some_and(|t| {
4595                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4596                            })
4597                        {
4598                            bail!(
4599                                "merge decision is stale or no longer authorizes the reviewed head"
4600                            );
4601                        }
4602                    }
4603                    forge.merge(&repo, &argv).await?
4604                };
4605                if out.0 {
4606                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4607                    // enqueues (or reports the pull request already queued)
4608                    // and succeeds while it is still open. Ask the forge; an
4609                    // unreadable answer is not a confirmation either, so it
4610                    // is looked at again rather than recorded as merged.
4611                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4612                        if c.pr.state == PrLifecycle::Merged {
4613                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4614                            true
4615                        } else {
4616                            false
4617                        }
4618                    });
4619                    if !confirmed {
4620                        if waited >= WAIT_CEILING {
4621                            let why = "the merge request succeeded but the pull request \
4622                                       could not be confirmed merged after waiting";
4623                            stop(state, &repo, &pr, why).await?;
4624                            return Ok(pr);
4625                        }
4626                        state.event(
4627                            "land",
4628                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4629                        );
4630                        state.save()?;
4631                        waited += POLL;
4632                        forge.poll().await;
4633                        continue;
4634                    }
4635                    pr.state = PrLifecycle::Merged;
4636                    state.status = RunStatus::Merged;
4637                    state.merge = Some(MergeOutcome {
4638                        mode: MergeMode::Pr,
4639                        ok: true,
4640                        detail: format!("gh {}", argv.join(" ")),
4641                        empty: false,
4642                    });
4643                    // The last `state.pr` snapshot is whatever the poll before
4644                    // this merge observed - still `open` - and nothing below
4645                    // refreshes it from GitHub again, so the UI's round rail
4646                    // would otherwise keep animating a merged run forever.
4647                    if let Some(pr_record) = state.pr.as_mut() {
4648                        pr_record.state = pr.state.as_str().to_owned();
4649                    }
4650                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4651                    announce_red_merge(state, &pr).await;
4652                    state.save()?;
4653                    write_pr_state_through(state, pr.state);
4654                    return Ok(pr);
4655                }
4656                let after_seen = forge.view(&repo, pr_url).await.ok();
4657                let after = after_seen.as_ref().map(|s| s.pr.state);
4658                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4659                    state.pr_merged_head = after_seen
4660                        .as_ref()
4661                        .map(|s| s.head.clone())
4662                        .filter(|h| !h.is_empty());
4663                    pr.state = PrLifecycle::Merged;
4664                    state.status = RunStatus::Merged;
4665                    state.merge = Some(outcome);
4666                    if let Some(pr_record) = state.pr.as_mut() {
4667                        pr_record.state = pr.state.as_str().to_owned();
4668                    }
4669                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4670                    announce_red_merge(state, &pr).await;
4671                    state.save()?;
4672                    write_pr_state_through(state, pr.state);
4673                    return Ok(pr);
4674                }
4675                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head);
4676                match verdict {
4677                    Refused::Final => {
4678                        let merge_state = after_seen
4679                            .as_ref()
4680                            .map(|s| s.merge_state.as_str())
4681                            .filter(|m| !m.is_empty())
4682                            .unwrap_or("unknown");
4683                        // Which refusal this was decides what a person has to
4684                        // do about it, so the two are worded apart; both carry
4685                        // the forge's own message.
4686                        let why = format!(
4687                            "the merge was refused: {} (merge state: {merge_state})",
4688                            out.1
4689                        );
4690                        stop(state, &repo, &pr, &why).await?;
4691                        return Ok(pr);
4692                    }
4693                    verdict => {
4694                        // Back to the top, which re-decides and passes the
4695                        // approval gate again, a poll later. `waited` is not
4696                        // reset here: only a pushed fix restarts it, or a
4697                        // standing refusal would wait forever.
4698                        if waited >= WAIT_CEILING {
4699                            let why = format!(
4700                                "the merge was still refused after {} minutes: {}",
4701                                WAIT_CEILING.as_secs() / 60,
4702                                out.1
4703                            );
4704                            stop(state, &repo, &pr, &why).await?;
4705                            return Ok(pr);
4706                        }
4707                        if verdict == Refused::Recheck {
4708                            rechecked = true;
4709                        }
4710                        state.event(
4711                            "land",
4712                            "merge refused while the branch policy is not satisfied yet; waiting",
4713                        );
4714                        state.save()?;
4715                        waited += POLL;
4716                        forge.poll().await;
4717                    }
4718                }
4719            }
4720            Step::Rebase => {
4721                // Bounded by the same budget as a fix, because a rebase that
4722                // keeps being needed means the base moves faster than this
4723                // run can land and a person should decide what to do. It
4724                // spends none of that budget: the change is not what is
4725                // wrong.
4726                if rebases >= budget {
4727                    let why = format!(
4728                        "the base moved under this branch {budget} time(s) and it still does \
4729                         not merge; rebasing again would only race it"
4730                    );
4731                    stop(state, &repo, &pr, &why).await?;
4732                    return Ok(pr);
4733                }
4734                rebases += 1;
4735                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4736                    stop(
4737                        state,
4738                        &repo,
4739                        &pr,
4740                        "the pull request conflicts and this run has no winning branch to rebase",
4741                    )
4742                    .await?;
4743                    return Ok(pr);
4744                };
4745                let base = state.base_branch.clone();
4746                state.event(
4747                    "land",
4748                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4749                );
4750                state.save()?;
4751
4752                // Onto the base as the *remote* has it: the local ref may be
4753                // behind, and rebasing onto a stale base produces a branch
4754                // that conflicts all over again.
4755                let remote = state.config.merge.remote.clone();
4756                git::fetch(&repo, &remote, &base).await.ok();
4757                if owner_stop(state, &mut pr, forge).await? {
4758                    return Ok(pr);
4759                }
4760                let onto = format!("{remote}/{base}");
4761                let rebased = match rebase_landing_worktree(state, &onto).await {
4762                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4763                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4764                    Err(e) => Err(e),
4765                };
4766                if owner_stop(state, &mut pr, forge).await? {
4767                    return Ok(pr);
4768                }
4769                match rebased {
4770                    Ok(None) => {
4771                        let published =
4772                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4773                        if owner_stop(state, &mut pr, forge).await? {
4774                            return Ok(pr);
4775                        }
4776                        let head = published?;
4777                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4778                        state.pr_pushed_head = Some(head.clone());
4779                        awaiting_head = Some(head);
4780                        rechecked = false;
4781                        state.event("land", format!("rebased {branch} onto {base}"));
4782                        state.save()?;
4783                        // The forge has to re-run its checks against the
4784                        // rebased head before anything else can be decided.
4785                        waited = Duration::ZERO;
4786                        tokio::time::sleep(POLL).await;
4787                    }
4788                    // The fixer's rounds are spent (or it could not finish):
4789                    // that is a decision for a person.
4790                    Ok(Some(conflict)) => {
4791                        let why = format!(
4792                            "{} conflicts with {base} and the rebase did not apply: {}",
4793                            pr.url,
4794                            conflict.chars().take(600).collect::<String>()
4795                        );
4796                        stop(state, &repo, &pr, &why).await?;
4797                        return Ok(pr);
4798                    }
4799                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4800                    Err(e) => {
4801                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4802                        stop(state, &repo, &pr, &why).await?;
4803                        return Ok(pr);
4804                    }
4805                }
4806            }
4807            Step::GiveUp { reason } => {
4808                stop(state, &repo, &pr, &reason).await?;
4809                return Ok(pr);
4810            }
4811            Step::Fix { reason } => {
4812                round += 1;
4813                waited = Duration::ZERO;
4814                for c in &pr.review_comments {
4815                    shown.insert(c.body.clone());
4816                }
4817                state.event("land", format!("round {round}: {reason}"));
4818                state.save()?;
4819
4820                let logs = failing_logs(&repo, &seen.failing_urls).await;
4821                let was_red = pr.checks == Checks::Red;
4822                if owner_stop(state, &mut pr, forge).await? {
4823                    return Ok(pr);
4824                }
4825                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4826                if owner_stop(state, &mut pr, forge).await? {
4827                    return Ok(pr);
4828                }
4829                match fixed {
4830                    Fixed::Committed { head } => {
4831                        // Whatever the next look shows may still be the
4832                        // previous head; see `awaiting_new_head`.
4833                        state.pr_pushed_head = Some(head.clone());
4834                        awaiting_head = Some(head);
4835                        rechecked = false;
4836                        waited = Duration::ZERO;
4837                        forge.poll().await;
4838                    }
4839                    Fixed::Declined if was_red => {
4840                        let why = format!(
4841                            "the fixer produced no commit while {} check(s) were failing \
4842                             ({}); stopping instead of looping on an unchanged tree",
4843                            pr.failing.len(),
4844                            pr.failing.join(", ")
4845                        );
4846                        stop(state, &repo, &pr, &why).await?;
4847                        return Ok(pr);
4848                    }
4849                    // Comment-driven round with no commit: the fixer read the
4850                    // comments and changed nothing, which is a decision it is
4851                    // allowed to make. The comments are recorded as shown, so
4852                    // the next observation sees a clean pull request.
4853                    Fixed::Declined => state.event(
4854                        "land",
4855                        format!("round {round}: fixer declined the comments, nothing committed"),
4856                    ),
4857                    Fixed::Failed(why) => {
4858                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4859                        return Ok(pr);
4860                    }
4861                }
4862                state.save()?;
4863            }
4864        }
4865    }
4866}
4867
4868/// One observation, plus the two things [`PrState`] deliberately does not carry:
4869/// the title (needed for the squash subject) and where the failing checks'
4870/// logs live.
4871#[derive(Clone)]
4872struct Seen {
4873    pr: PrState,
4874    title: String,
4875    failing_urls: Vec<(String, String)>,
4876    /// `headRefOid`: the commit this observation, checks included, is about.
4877    head: String,
4878    /// The commit the checks belong to, read from the same GraphQL node as
4879    /// the checks themselves. Empty when unreadable.
4880    rollup_head: String,
4881    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
4882    /// BEHIND and DRAFT together, and a stop reason has to say which.
4883    merge_state: String,
4884    /// Every check of the rollup, for naming what an armed merge waits on.
4885    contexts: Vec<CheckInfo>,
4886    /// `baseRefName`, to look up which contexts the base requires.
4887    base: String,
4888}
4889
4890/// One check of the rollup as far as a stop reason needs it.
4891#[derive(Clone)]
4892struct CheckInfo {
4893    label: String,
4894    verdict: Verdict,
4895    required: Option<bool>,
4896    /// The workflow run the check belongs to; `None` for an external status.
4897    run: Option<String>,
4898}
4899
4900/// What to do with a red head before a fix round is spent on it.
4901#[derive(Debug, Clone, PartialEq, Eq)]
4902enum RerunStep {
4903    /// Rerun these workflow runs (each not yet rerun for this head).
4904    Rerun(Vec<String>),
4905    /// Reran already and the forge may still report the old failure: wait.
4906    Grace,
4907    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
4908    Fix,
4909}
4910
4911/// Failed checks a rerun may help: a failure of a check that blocks (`required`
4912/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
4913/// pending checks in another run do not delay this run's rerun.
4914fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
4915    contexts
4916        .iter()
4917        .filter(|c| c.verdict == Verdict::Fail && c.required != Some(false))
4918        // `gh run rerun --failed` refuses a run with a job still going.
4919        .filter(|c| {
4920            c.run.as_ref().is_none_or(|r| {
4921                !contexts.iter().any(|o| {
4922                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
4923                        && o.run.as_ref() == Some(r)
4924                })
4925            })
4926        })
4927        .map(|c| (c.label.clone(), c.run.clone()))
4928        .collect()
4929}
4930
4931/// Pure: rerun once per run id, wait out the grace, then hand over.
4932fn rerun_step(
4933    failing: &[(String, Option<String>)],
4934    record: Option<&LandReruns>,
4935    head: &str,
4936    now: i64,
4937) -> RerunStep {
4938    // A failure with no workflow run (an external status) cannot be rerun, and
4939    // the fixer's push re-runs everything anyway.
4940    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
4941        return RerunStep::Fix;
4942    }
4943    // Reruns belong to the head they were made on.
4944    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
4945    let mut fresh: Vec<String> = Vec::new();
4946    let mut recent = false;
4947    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
4948        match record.and_then(|r| r.runs.get(run)) {
4949            None => {
4950                if !fresh.iter().any(|f| f == run) {
4951                    fresh.push(run.to_owned());
4952                }
4953            }
4954            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
4955            Some(_) => {}
4956        }
4957    }
4958    if !fresh.is_empty() {
4959        RerunStep::Rerun(fresh)
4960    } else if recent {
4961        RerunStep::Grace
4962    } else {
4963        RerunStep::Fix
4964    }
4965}
4966
4967/// Read the pull request: `gh pr view` for the top-level thread and merge
4968/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
4969/// inline review comments `gh pr view` does not report.
4970async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
4971    let view = gh(
4972        repo,
4973        &[
4974            "pr".to_owned(),
4975            "view".to_owned(),
4976            pr_url.to_owned(),
4977            "--json".to_owned(),
4978            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
4979                .to_owned(),
4980        ],
4981    )
4982    .await?;
4983    if !view.0 {
4984        bail!("gh pr view {pr_url}: {}", view.1);
4985    }
4986    let number = parse_pr(&view.1)?.number;
4987    let node = last_commit_node(repo, number).await;
4988    let mut seen = seen_from(&view.1, node.as_deref())?;
4989
4990    let inline = gh(
4991        repo,
4992        &[
4993            "api".to_owned(),
4994            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
4995        ],
4996    )
4997    .await?;
4998    if inline.0 {
4999        match parse_inline_comments(&inline.1) {
5000            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5001            // An unreadable inline thread must not end a landing: the rollup
5002            // and the top-level thread are still real signal.
5003            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5004        }
5005    } else {
5006        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5007    }
5008    Ok(seen)
5009}
5010
5011/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5012/// response. No I/O.
5013///
5014/// The commit oid and the checks are read from the *same* node, so the rollup
5015/// is bound to the commit it belongs to by construction; two reads that agree
5016/// before and after another response prove nothing about what that response
5017/// held. The view's own rollup is never used. A node that is missing,
5018/// unreadable, carries GraphQL errors, or whose checks run past the page
5019/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5020/// as "look again" and never as a reason to merge.
5021fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5022    let mut pr = parse_pr(view_json)?;
5023    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5024
5025    let mut rollup_head = String::new();
5026    let mut failing_urls = Vec::new();
5027    let mut contexts = Vec::new();
5028    let mut checks = Checks::Unknown;
5029    let mut failing = Vec::new();
5030    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5031        (checks, failing) = rollup_verdict(&rollup);
5032        failing_urls = rollup
5033            .iter()
5034            .filter(|c| c.verdict() == Verdict::Fail)
5035            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5036            .collect();
5037        contexts = rollup
5038            .iter()
5039            .map(|c| CheckInfo {
5040                label: c.label(),
5041                verdict: c.verdict(),
5042                required: c.is_required,
5043                run: c.url().and_then(run_of),
5044            })
5045            .collect();
5046        rollup_head = oid;
5047    }
5048    pr.checks = checks;
5049    pr.failing = failing;
5050
5051    Ok(Seen {
5052        pr,
5053        title: raw.title,
5054        failing_urls,
5055        head: raw.head_ref_oid,
5056        rollup_head,
5057        merge_state: raw.merge_state_status,
5058        contexts,
5059        base: raw.base_ref_name,
5060    })
5061}
5062
5063/// The last commit's oid and its checks from one GraphQL response, `None`
5064/// when anything about it cannot be trusted.
5065fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5066    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5067    if v.get("errors").is_some_and(|e| !e.is_null()) {
5068        return None;
5069    }
5070    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5071    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5072    let contexts = commit.pointer("/statusCheckRollup/contexts");
5073    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5074        // No rollup at all: the commit has no checks.
5075        return Some((oid.to_owned(), Vec::new()));
5076    };
5077    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5078        return None;
5079    }
5080    let nodes = contexts.get("nodes")?.as_array()?;
5081    let rollup = nodes
5082        .iter()
5083        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5084        .collect::<Result<Vec<_>, _>>()
5085        .ok()?;
5086    Some((oid.to_owned(), rollup))
5087}
5088
5089/// The pull request's last commit and its checks, in one response. `None`
5090/// when the forge could not be asked.
5091async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5092    let out = gh(
5093        repo,
5094        &[
5095            "api".to_owned(),
5096            "graphql".to_owned(),
5097            "-F".to_owned(),
5098            "owner={owner}".to_owned(),
5099            "-F".to_owned(),
5100            "repo={repo}".to_owned(),
5101            "-F".to_owned(),
5102            format!("number={number}"),
5103            "-f".to_owned(),
5104            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5105             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5106             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5107             ... on CheckRun{name status conclusion detailsUrl \
5108             isRequired(pullRequestNumber:$number)} \
5109             ... on StatusContext{context state targetUrl \
5110             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5111                .to_owned(),
5112        ],
5113    )
5114    .await
5115    .ok()?;
5116    out.0.then_some(out.1)
5117}
5118
5119/// What a fix round did.
5120#[doc(hidden)]
5121#[derive(Debug, PartialEq)]
5122pub enum Fixed {
5123    /// The fixer committed something, and this is the head that was pushed.
5124    Committed {
5125        /// The commit now at the tip of the pushed branch.
5126        head: String,
5127    },
5128    /// The fixer ran and chose to change nothing.
5129    Declined,
5130    /// The fixer could not run, or said nothing usable.
5131    Failed(String),
5132}
5133
5134/// Hand the failures and the comments to the fixer, then commit and push.
5135///
5136/// The fixer works in the winner's own worktree so its commits land on the
5137/// branch the pull request is built from, and it runs with `allow_write` for
5138/// the same reason.
5139#[doc(hidden)]
5140pub async fn fix_round(
5141    state: &mut RunState,
5142    pr: &PrState,
5143    round: usize,
5144    budget: usize,
5145    reason: &str,
5146    logs: &str,
5147) -> Result<Fixed> {
5148    let winner = state
5149        .winner()
5150        .cloned()
5151        .context("landing needs a winning candidate; none is recorded on this run")?;
5152    let roles = state
5153        .config
5154        .resolve_roles()
5155        .context("resolve the roster for the fix round")?;
5156    // Same chain as the review loop (see `crate::fixer`): the configured
5157    // fixers in order, otherwise the winner's own author continuing its own
5158    // conversation - the competition is over, so its context is pure benefit.
5159    let attempts = crate::fixer::attempts(state, &roles, &winner);
5160    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5161
5162    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5163    let mut artifacts = agent::artifacts_dir(&state.dir());
5164    if let Some(request) = state
5165        .merge_repairs
5166        .last()
5167        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5168    {
5169        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5170    }
5171    let prompt = if state.config.cache_dir().is_some() {
5172        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5173    } else {
5174        prompt
5175    };
5176    // Read before the fixer runs: it normally commits for itself, so HEAD has
5177    // already moved by the time it returns and a later read would see no
5178    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5179    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5180    // Each id at most once, forward only: the next one is asked only when the
5181    // call advances, and only the last attempt's result is judged below, so an
5182    // exhausted chain ends as a single failed fixer does.
5183    let mut last = None;
5184    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5185        if owner_requested_stop(state)? {
5186            return Ok(Fixed::Declined);
5187        }
5188        let mut seat = seat_of(state, &seat_key, &spec.id);
5189        let stem = if i == 0 {
5190            format!("land-{round}")
5191        } else {
5192            format!("land-{round}-{}", spec.id)
5193        };
5194        let out = agent::invoke(
5195            &spec,
5196            &mut seat,
5197            &Invocation {
5198                scope: crate::agent::InvocationScope::Repository,
5199                coordination: state.config.coordination.as_ref(),
5200                fetched_base: state.fetched_base.as_ref(),
5201                cwd: &winner.worktree,
5202                prompt: &prompt,
5203                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5204                allow_write: true,
5205                unsandboxed: false,
5206                sessions: state.config.graph.sessions,
5207                artifacts: &artifacts,
5208                stem: &stem,
5209                run: &state.id,
5210                node: "land",
5211                cache_dir: state.config.cache_dir().as_deref(),
5212                attachments: &[],
5213                writable: &[],
5214            },
5215        )
5216        .await;
5217        state.seats.insert(seat.key.clone(), seat);
5218        if let Some(next) = ids.get(i + 1)
5219            && agent::chain_advances(&out)
5220        {
5221            let (class, why) = crate::fixer::failure_of(&out);
5222            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5223            continue;
5224        }
5225        last = Some(out);
5226        break;
5227    }
5228    let out = last.expect("the fixer chain always has an entry");
5229
5230    match out {
5231        Ok(o) if o.quota_exhausted() => {
5232            return Ok(Fixed::Failed(
5233                "rate limited (quota); the fixer could not run".to_owned(),
5234            ));
5235        }
5236        Ok(o) if !o.usable() => {
5237            return Ok(Fixed::Failed(format!(
5238                "the fixer produced nothing usable ({})",
5239                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5240            )));
5241        }
5242        Ok(_) => {}
5243        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5244        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5245    }
5246
5247    if owner_requested_stop(state)? {
5248        return Ok(Fixed::Declined);
5249    }
5250
5251    // An agent that edited files but never committed would otherwise push
5252    // nothing and look like a refusal.
5253    let wait = crate::repo_lock::WaitPolicy::new(
5254        Duration::from_secs(state.config.graph.timeout_resource_wait),
5255        || false,
5256    );
5257    let coordination = crate::repo_lock::SeatLease::operation_with(
5258        &winner.worktree,
5259        state.config.coordination.as_ref(),
5260        &wait,
5261    )
5262    .await?;
5263    match git::rescue_commit(
5264        &winner.worktree,
5265        &format!("magi: land round {round} fixes (uncommitted work)"),
5266    )
5267    .await
5268    {
5269        Ok(r) => state.note_withheld("land", &r.withheld),
5270        Err(e)
5271            if state
5272                .merge_repairs
5273                .last()
5274                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5275        {
5276            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5277        }
5278        Err(_) => {}
5279    }
5280    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5281    drop(coordination);
5282    if after == before {
5283        return Ok(Fixed::Declined);
5284    }
5285
5286    if owner_requested_stop(state)? {
5287        return Ok(Fixed::Declined);
5288    }
5289    crate::pr_head::ensure_owner(state)?;
5290    let remote = state.config.merge.remote.clone();
5291    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5292    let push = if state.pr_head.is_some() {
5293        let expected = state
5294            .pr_pushed_head
5295            .as_deref()
5296            .context("missing public head lease")?;
5297        git::push_as(
5298            &winner.worktree,
5299            &remote,
5300            &winner.branch,
5301            &public_head,
5302            expected,
5303        )
5304        .await?
5305    } else {
5306        git::push(&winner.worktree, &remote, &winner.branch).await?
5307    };
5308    if !push.ok() {
5309        return Ok(Fixed::Failed(format!(
5310            "pushing {} to {remote} failed: {}",
5311            public_head, push.stderr
5312        )));
5313    }
5314    state.event(
5315        "land",
5316        format!("round {round}: pushed a fix to {public_head}"),
5317    );
5318    state.pr_pushed_head = Some(after.clone());
5319    state.save()?;
5320    Ok(Fixed::Committed { head: after })
5321}
5322
5323/// Fetch or create a seat, keeping its conversation across nodes.
5324pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5325    crate::fixer::seat_for(state, key, agent)
5326}
5327
5328/// Recheck the live PR and local worktree before spending the requested fix.
5329/// A moved/closed PR is held, never repaired using obsolete evidence.
5330pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5331    let seen = observe(&state.repo, url).await?;
5332    let winner = state.winner().context("no winning branch to repair")?;
5333    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5334        anyhow::bail!(
5335            "PR is closed or its head changed; review the current head before requesting repair again"
5336        );
5337    }
5338    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5339        || !git::is_clean(&winner.worktree).await?
5340    {
5341        anyhow::bail!(
5342            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5343        );
5344    }
5345    // Always disable any forge-side reservation, including one not recorded
5346    // by this run. A repair request is never merge authorization.
5347    let view = gh(
5348        &state.repo,
5349        &[
5350            "pr".into(),
5351            "view".into(),
5352            url.into(),
5353            "--json".into(),
5354            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5355        ],
5356    )
5357    .await?;
5358    if !view.0 {
5359        bail!("cannot check auto-merge: {}", view.1);
5360    }
5361    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5362    let public = state.pr_branch().unwrap_or(&winner.branch);
5363    if live["state"] != "OPEN"
5364        || live["headRefOid"] != head
5365        || live["headRefName"] != public
5366        || live["baseRefName"] != state.base_branch
5367        || live["isCrossRepository"] != false
5368    {
5369        bail!("PR changed during repair preflight; review its current head explicitly");
5370    }
5371    let auto_merge = live
5372        .get("autoMergeRequest")
5373        .context("cannot read the PR auto-merge reservation; repair held")?;
5374    if !auto_merge.is_null() {
5375        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5376        if !out.0 {
5377            bail!("cannot disable auto-merge; repair held: {}", out.1);
5378        }
5379    }
5380    Ok(seen.pr)
5381}
5382
5383/// What the fixer is told.
5384fn fix_prompt(
5385    state: &RunState,
5386    pr: &PrState,
5387    round: usize,
5388    budget: usize,
5389    reason: &str,
5390    logs: &str,
5391) -> String {
5392    let mut s = format!(
5393        "Your patch is open as a pull request and it is not landing. Land round \
5394         {round} of {budget}.\n\n\
5395         Pull request: {}\n\n\
5396         What is holding it: {reason}\n\n\
5397         # The task\n\n{}\n",
5398        pr.url, state.instruction
5399    );
5400
5401    if pr.failing.is_empty() {
5402        s.push_str("\n# Failing checks\n\n(none)\n");
5403    } else {
5404        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5405        if logs.trim().is_empty() {
5406            s.push_str(
5407                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5408            );
5409        } else {
5410            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5411        }
5412    }
5413
5414    if pr.review_comments.is_empty() {
5415        s.push_str("\n# Review comments\n\n(none)\n");
5416    } else {
5417        s.push_str("\n# Review comments\n");
5418        for c in &pr.review_comments {
5419            let where_ = match (&c.path, c.line) {
5420                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5421                (Some(p), None) => format!(" ({p})"),
5422                _ => String::new(),
5423            };
5424            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5425        }
5426    }
5427
5428    s.push_str(
5429        "\n# Rules\n\n\
5430         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5431            failing test; do not silence a lint with an allow attribute; do not \
5432            stretch a timeout to hide a race. If the check is right, the code is \
5433            wrong.\n\
5434         2. Change nothing the checks and the comments did not raise. A \
5435            drive-by refactor turns a one-line fix into a pull request that \
5436            needs reviewing again.\n\
5437         3. If a comment is wrong, say so with a checkable argument and change \
5438            nothing for it. A declined comment with a reason is a correct \
5439            outcome; a change made to appease a reviewer is not.\n\
5440         4. Commit in this worktree. magi pushes to the pull request's branch \
5441            for you; do not push, merge, or close anything yourself.\n\
5442         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5443         # Output\n\n\
5444         Say what you changed and why, and what you declined and why.",
5445    );
5446
5447    let language = &state.config.graph.language;
5448    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5449        let _ = write!(s, "\n\nWrite all prose in {language}.");
5450    }
5451    // After the language line, so the exception is the last word on it.
5452    s.push_str(&crate::prompt::github_rule(
5453        language,
5454        &state.config.graph.github_language,
5455    ));
5456    if let Some(overlay) = state.config.prompts.overlay("fix") {
5457        let _ = write!(s, "\n\n{overlay}");
5458    }
5459    s.push('\n');
5460    s.push_str(crate::prompt::child_run_ownership());
5461    s
5462}
5463
5464/// Failing log tails, the way the operator collects them by hand:
5465/// `gh run view --log-failed`.
5466async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5467    let mut out = String::new();
5468    for (name, url) in failing.iter().take(MAX_LOGS) {
5469        let args = match (job_of(url), run_of(url)) {
5470            (Some(job), _) => vec![
5471                "run".to_owned(),
5472                "view".to_owned(),
5473                "--log-failed".to_owned(),
5474                "--job".to_owned(),
5475                job,
5476            ],
5477            (None, Some(run)) => vec![
5478                "run".to_owned(),
5479                "view".to_owned(),
5480                run,
5481                "--log-failed".to_owned(),
5482            ],
5483            // Not a GitHub Actions check - an external status has no log here.
5484            (None, None) => continue,
5485        };
5486        let (ok, body) = match gh(repo, &args).await {
5487            Ok(v) => v,
5488            Err(e) => (false, format!("{e:#}")),
5489        };
5490        if !ok && body.trim().is_empty() {
5491            continue;
5492        }
5493        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5494    }
5495    out
5496}
5497
5498/// Job id out of a check's `detailsUrl`
5499/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5500fn job_of(details_url: &str) -> Option<String> {
5501    let after = details_url.split("/job/").nth(1)?;
5502    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5503    (!id.is_empty()).then_some(id)
5504}
5505
5506/// Workflow run id out of a check's `detailsUrl`.
5507pub(crate) fn run_of(details_url: &str) -> Option<String> {
5508    let after = details_url.split("/actions/runs/").nth(1)?;
5509    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5510    (!id.is_empty()).then_some(id)
5511}
5512
5513/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5514/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5515/// it to that.
5516fn stop_comment(run_id: &str, why: &str) -> String {
5517    format!(
5518        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5519         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5520    )
5521}
5522
5523/// Leave the pull request open, say why on it, and mark the run blocked.
5524///
5525/// The comment is what makes an unattended stop actionable: the operator wakes
5526/// up to a pull request that explains itself rather than to a silent queue.
5527async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5528    let body = stop_comment(&state.id, why);
5529    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5530    if !body.contains(MARKER) {
5531        body = format!("{MARKER}\n{body}");
5532    }
5533    let posted = gh(
5534        repo,
5535        &[
5536            "pr".to_owned(),
5537            "comment".to_owned(),
5538            pr.number.to_string(),
5539            "--body".to_owned(),
5540            body,
5541        ],
5542    )
5543    .await;
5544    match posted {
5545        Ok((true, _)) => {}
5546        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5547        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5548    }
5549    state.status = RunStatus::Blocked;
5550    state.merge = Some(MergeOutcome {
5551        mode: MergeMode::Pr,
5552        ok: false,
5553        detail: why.to_owned(),
5554        empty: false,
5555    });
5556    state.event("land", format!("stopped: {why}"));
5557    state.save()?;
5558    Ok(())
5559}
5560
5561/// Run `gh` in `repo`, returning success and the combined output.
5562///
5563/// Combined because `gh` reports a refused merge on stderr and the pull request
5564/// json on stdout, and both are evidence.
5565///
5566/// `GH_REPO` is stripped from the child's environment: every call site here
5567/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5568/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5569/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5570/// `GH_REPO` the operator happens to have exported for an unrelated script
5571/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5572/// below) to a different repository than the one actually on disk - which
5573/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5574/// could be made to agree with whatever repository a forged `--merged` URL
5575/// claims, defeating it entirely.
5576pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5577    let out = tokio::process::Command::new("gh")
5578        .args(args)
5579        .current_dir(cwd)
5580        .env_remove("GH_REPO")
5581        .quiet()
5582        .stdin(std::process::Stdio::null())
5583        .output()
5584        .await
5585        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5586    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5587    let err = String::from_utf8_lossy(&out.stderr);
5588    if body.trim().is_empty() {
5589        body = err.into_owned();
5590    } else if !err.trim().is_empty() {
5591        body.push_str(&err);
5592    }
5593    Ok((out.status.success(), body.trim().to_owned()))
5594}
5595
5596/// Verdict of one entry in the status rollup.
5597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5598pub(crate) enum Verdict {
5599    Pass,
5600    Fail,
5601    Pending,
5602    Unknown,
5603}
5604
5605impl Verdict {
5606    /// Short operator-facing check result.
5607    pub(crate) fn label(self) -> &'static str {
5608        match self {
5609            Self::Pass => "passed",
5610            Self::Fail => "failed",
5611            Self::Pending => "pending",
5612            Self::Unknown => "unknown",
5613        }
5614    }
5615}
5616
5617#[derive(Debug, Deserialize)]
5618#[serde(rename_all = "camelCase")]
5619struct GhPr {
5620    #[serde(default)]
5621    url: String,
5622    #[serde(default)]
5623    number: u64,
5624    #[serde(default)]
5625    state: String,
5626    #[serde(default)]
5627    title: String,
5628    #[serde(default)]
5629    status_check_rollup: Vec<GhCheck>,
5630    /// GitHub's own verdict on whether the pull request can be merged.
5631    ///
5632    /// Worth asking for because it is the only place the *required* check set
5633    /// is applied: the rollup lists every check equally, so a repository that
5634    /// deliberately does not require `coverage` still looks red here. See
5635    /// [`Blocking`].
5636    #[serde(default)]
5637    merge_state_status: String,
5638    /// The commit the pull request currently points at. Compared with the
5639    /// commit a fix round pushed, it is how the loop knows the forge has moved
5640    /// on and the rollup belongs to the new head.
5641    #[serde(default)]
5642    head_ref_oid: String,
5643    #[serde(default)]
5644    base_ref_name: String,
5645    #[serde(default)]
5646    reviews: Vec<GhReview>,
5647    #[serde(default)]
5648    comments: Vec<GhComment>,
5649}
5650
5651/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5652/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5653/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5654/// conclusion at all.
5655#[derive(Debug, Deserialize)]
5656#[serde(rename_all = "camelCase")]
5657struct GhCheck {
5658    #[serde(default)]
5659    name: Option<String>,
5660    #[serde(default)]
5661    context: Option<String>,
5662    #[serde(default)]
5663    status: Option<String>,
5664    #[serde(default)]
5665    conclusion: Option<String>,
5666    #[serde(default)]
5667    state: Option<String>,
5668    #[serde(default)]
5669    details_url: Option<String>,
5670    #[serde(default)]
5671    target_url: Option<String>,
5672    /// Whether the base branch requires this check. Only the GraphQL node
5673    /// carries it; `None` is "not read", never "not required".
5674    #[serde(default)]
5675    is_required: Option<bool>,
5676}
5677
5678impl GhCheck {
5679    /// Name to show a human and hand to the fixer.
5680    fn label(&self) -> String {
5681        self.name
5682            .clone()
5683            .or_else(|| self.context.clone())
5684            .unwrap_or_else(|| "(unnamed check)".to_owned())
5685    }
5686
5687    /// Where this check's logs live, when it has any.
5688    fn url(&self) -> Option<&str> {
5689        self.details_url
5690            .as_deref()
5691            .or(self.target_url.as_deref())
5692            .filter(|u| !u.is_empty())
5693    }
5694
5695    /// Did it pass?
5696    ///
5697    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
5698    /// skips release and bot pull requests by design, and a skip that blocked
5699    /// landing would block exactly the pull requests that need no review.
5700    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
5701    /// merging over one is merging over a check that never ran.
5702    fn verdict(&self) -> Verdict {
5703        if let Some(status) = self.status.as_deref() {
5704            if !status.eq_ignore_ascii_case("COMPLETED") {
5705                return Verdict::Pending;
5706            }
5707        }
5708        let outcome = self
5709            .conclusion
5710            .as_deref()
5711            .or(self.state.as_deref())
5712            .unwrap_or("");
5713        match outcome.to_ascii_uppercase().as_str() {
5714            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
5715            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
5716            | "ACTION_REQUIRED" => Verdict::Fail,
5717            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
5718                Verdict::Pending
5719            }
5720            _ => Verdict::Unknown,
5721        }
5722    }
5723}
5724
5725#[derive(Debug, Deserialize)]
5726struct GhAuthor {
5727    #[serde(default)]
5728    login: String,
5729}
5730
5731#[derive(Debug, Deserialize)]
5732struct GhReview {
5733    #[serde(default)]
5734    author: GhAuthor,
5735    #[serde(default)]
5736    body: String,
5737}
5738
5739#[derive(Debug, Deserialize)]
5740struct GhComment {
5741    #[serde(default)]
5742    author: GhAuthor,
5743    #[serde(default)]
5744    body: String,
5745}
5746
5747#[derive(Debug, Deserialize)]
5748struct GhUser {
5749    #[serde(default)]
5750    login: String,
5751}
5752
5753#[derive(Debug, Deserialize)]
5754struct GhInline {
5755    #[serde(default)]
5756    user: GhUser,
5757    #[serde(default)]
5758    path: Option<String>,
5759    #[serde(default)]
5760    line: Option<u64>,
5761    #[serde(default)]
5762    body: String,
5763}
5764
5765impl Default for GhAuthor {
5766    fn default() -> Self {
5767        Self {
5768            login: "(unknown)".to_owned(),
5769        }
5770    }
5771}
5772
5773impl Default for GhUser {
5774    fn default() -> Self {
5775        Self {
5776            login: "(unknown)".to_owned(),
5777        }
5778    }
5779}
5780
5781#[cfg(test)]
5782pub(crate) mod tests {
5783    use super::*;
5784    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
5785
5786    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
5787        format!(
5788            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
5789        )
5790    }
5791
5792    #[test]
5793    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
5794        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
5795        assert_eq!(
5796            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
5797            Ok(())
5798        );
5799        for (json, why) in [
5800            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
5801            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
5802            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
5803            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
5804            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
5805            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
5806        ] {
5807            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
5808                .unwrap_err()
5809                .why;
5810            assert!(err.contains(why), "{json}: {err}");
5811        }
5812        // A head that moved on past what was verified is left alone.
5813        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
5814        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
5815        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
5816        assert!(
5817            !closable(
5818                &head_json("x", "main", "OPEN", false),
5819                "magi/27b2/A",
5820                "main",
5821                &[]
5822            )
5823            .unwrap_err()
5824            .retry
5825        );
5826        assert!(is_forge_url("https://github.com/o/r.git"));
5827        assert!(is_forge_url("git@github.com:o/r.git"));
5828        assert!(!is_forge_url("/tmp/origin.git"));
5829        assert!(!is_forge_url("C:\\work\\origin.git"));
5830        assert!(!is_forge_url("file:///tmp/origin.git"));
5831        assert!(forge_unavailable(
5832            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
5833        ));
5834        assert!(!forge_unavailable(
5835            "gh pr list failed: error connecting to api.github.com"
5836        ));
5837        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
5838        // A record that does not say whether it is a fork is not trusted.
5839        assert!(
5840            closable(
5841                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
5842                "b",
5843                "main",
5844                &["aaa".to_owned()]
5845            )
5846            .is_err()
5847        );
5848    }
5849
5850    #[test]
5851    fn the_close_comment_names_the_commit_on_the_base() {
5852        let e = crate::already::Evidence {
5853            proof: crate::already::Proof::PatchId,
5854            tip: "1234567890".to_owned(),
5855            commits: vec!["0e368de0000".to_owned()],
5856        };
5857        let c = superseded_comment("main", &e);
5858        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
5859    }
5860
5861    /// 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.
5862    const GREEN_OPEN: &str = r####"{
5863  "url": "https://github.com/yukimemi/magi/pull/10",
5864  "number": 10,
5865  "state": "OPEN",
5866  "mergeStateStatus": "CLEAN",
5867  "statusCheckRollup": [
5868    {
5869      "__typename": "CheckRun",
5870      "conclusion": "SKIPPED",
5871      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
5872      "name": "review",
5873      "status": "COMPLETED",
5874      "workflowName": "claude-review"
5875    },
5876    {
5877      "__typename": "CheckRun",
5878      "conclusion": "SUCCESS",
5879      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
5880      "name": "check (ubuntu-latest)",
5881      "status": "COMPLETED",
5882      "workflowName": "CI"
5883    },
5884    {
5885      "__typename": "CheckRun",
5886      "conclusion": "SUCCESS",
5887      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
5888      "name": "rustfmt",
5889      "status": "COMPLETED",
5890      "workflowName": "CI"
5891    },
5892    {
5893      "__typename": "StatusContext",
5894      "context": "CodeRabbit",
5895      "state": "SUCCESS",
5896      "targetUrl": ""
5897    }
5898  ],
5899  "reviews": [],
5900  "comments": [
5901    {
5902      "author": {
5903        "login": "coderabbitai"
5904      },
5905      "authorAssociation": "NONE",
5906      "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"
5907    }
5908  ]
5909}"####;
5910
5911    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
5912    const RED_OPEN: &str = r####"{
5913  "url": "https://github.com/yukimemi/magi/pull/9",
5914  "number": 9,
5915  "state": "OPEN",
5916  "mergeStateStatus": "UNSTABLE",
5917  "statusCheckRollup": [
5918    {
5919      "__typename": "CheckRun",
5920      "conclusion": "SUCCESS",
5921      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5922      "name": "check (ubuntu-latest)",
5923      "status": "COMPLETED",
5924      "workflowName": "CI"
5925    },
5926    {
5927      "__typename": "CheckRun",
5928      "conclusion": "SUCCESS",
5929      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5930      "name": "rustfmt",
5931      "status": "COMPLETED",
5932      "workflowName": "CI"
5933    },
5934    {
5935      "__typename": "CheckRun",
5936      "conclusion": "FAILURE",
5937      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5938      "name": "editorconfig",
5939      "status": "COMPLETED",
5940      "workflowName": "CI"
5941    },
5942    {
5943      "__typename": "StatusContext",
5944      "context": "CodeRabbit",
5945      "state": "SUCCESS",
5946      "targetUrl": ""
5947    }
5948  ],
5949  "reviews": [],
5950  "comments": [
5951    {
5952      "author": {
5953        "login": "coderabbitai"
5954      },
5955      "authorAssociation": "NONE",
5956      "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"
5957    }
5958  ]
5959}"####;
5960
5961    /// 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.
5962    const PENDING_OPEN: &str = r####"{
5963  "url": "https://github.com/yukimemi/magi/pull/9",
5964  "number": 9,
5965  "state": "OPEN",
5966  "statusCheckRollup": [
5967    {
5968      "__typename": "CheckRun",
5969      "conclusion": "SUCCESS",
5970      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
5971      "name": "check (ubuntu-latest)",
5972      "status": "COMPLETED",
5973      "workflowName": "CI"
5974    },
5975    {
5976      "__typename": "CheckRun",
5977      "conclusion": "SUCCESS",
5978      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
5979      "name": "rustfmt",
5980      "status": "COMPLETED",
5981      "workflowName": "CI"
5982    },
5983    {
5984      "__typename": "CheckRun",
5985      "conclusion": null,
5986      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
5987      "name": "editorconfig",
5988      "status": "IN_PROGRESS",
5989      "workflowName": "CI"
5990    },
5991    {
5992      "__typename": "StatusContext",
5993      "context": "CodeRabbit",
5994      "state": "SUCCESS",
5995      "targetUrl": ""
5996    }
5997  ],
5998  "reviews": [],
5999  "comments": []
6000}"####;
6001
6002    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6003    const MERGED: &str = r####"{
6004  "url": "https://github.com/yukimemi/magi/pull/16",
6005  "number": 16,
6006  "state": "MERGED",
6007  "statusCheckRollup": [
6008    {
6009      "__typename": "CheckRun",
6010      "conclusion": "SUCCESS",
6011      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6012      "name": "check (ubuntu-latest)",
6013      "status": "COMPLETED",
6014      "workflowName": "CI"
6015    },
6016    {
6017      "__typename": "CheckRun",
6018      "conclusion": "SUCCESS",
6019      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6020      "name": "review",
6021      "status": "COMPLETED",
6022      "workflowName": "claude-review"
6023    }
6024  ],
6025  "reviews": [],
6026  "comments": []
6027}"####;
6028
6029    /// 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.
6030    const REVIEWED_OPEN: &str = r####"{
6031  "url": "https://github.com/yukimemi/magi/pull/12",
6032  "number": 12,
6033  "state": "OPEN",
6034  "statusCheckRollup": [
6035    {
6036      "__typename": "CheckRun",
6037      "conclusion": "SUCCESS",
6038      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6039      "name": "check (ubuntu-latest)",
6040      "status": "COMPLETED",
6041      "workflowName": "CI"
6042    },
6043    {
6044      "__typename": "CheckRun",
6045      "conclusion": "SUCCESS",
6046      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6047      "name": "review",
6048      "status": "COMPLETED",
6049      "workflowName": "claude-review"
6050    }
6051  ],
6052  "reviews": [
6053    {
6054      "author": {
6055        "login": "claude"
6056      },
6057      "state": "COMMENTED",
6058      "body": ""
6059    }
6060  ],
6061  "comments": [
6062    {
6063      "author": {
6064        "login": "coderabbitai"
6065      },
6066      "authorAssociation": "NONE",
6067      "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"
6068    },
6069    {
6070      "author": {
6071        "login": "claude"
6072      },
6073      "authorAssociation": "NONE",
6074      "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"
6075    }
6076  ]
6077}"####;
6078
6079    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6080    const INLINE: &str = r####"[
6081  {
6082    "user": {
6083      "login": "claude[bot]"
6084    },
6085    "path": "src/graph.rs",
6086    "line": 231,
6087    "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"
6088  }
6089]"####;
6090
6091    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6092    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6093<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6094
6095> [!IMPORTANT]
6096> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6097> 
6098> This repository does not receive automatic reviews because it has fewer than 10 stars.
6099> 
6100> <details>
6101> <summary>⚙️ Run configuration</summary>
6102> 
6103> **Configuration used**: defaults
6104> 
6105> **Review profile**: CHILL
6106> 
6107> **Plan**: Team
6108> 
6109> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6110> 
6111> </details>
6112
6113<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6114
6115<!-- tips_start -->
6116
6117---
6118
6119Thanks 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.
6120
6121<details>
6122<summary>❤️ Share</summary>
6123
6124- [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"####;
6125
6126    /// 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.
6127    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6128
6129---
6130### Reviewing PR #16
6131
6132- [x] Read AGENTS.md conventions
6133- [x] Review `src/daemon.rs` changes
6134- [x] Review `src/main.rs` changes (new `doctor` reporting)
6135- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6136- [x] Check test coverage for new behavior
6137- [x] Run verification commands (blocked — see note)
6138- [x] Post findings"####;
6139
6140    /// The same job's real comment on pull request #12 once it had something to say.
6141    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6142
6143---
6144### Review: `magi review <branch>` — cheap-half-only graph
6145
6146Read 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.
6147
6148**Correctness**
6149
6150- 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"####;
6151
6152    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6153        PrState {
6154            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6155            number: 16,
6156            state: PrLifecycle::Open,
6157            checks,
6158            // These tests are about red-means-fix, so a red here is one the
6159            // forge gates on. Without saying so they would assert the new
6160            // "merge past a check nobody requires" path by accident.
6161            blocking: if matches!(checks, Checks::Red) {
6162                Blocking::Yes
6163            } else {
6164                Blocking::No
6165            },
6166            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6167            review_comments: (0..comments)
6168                .map(|i| ReviewComment {
6169                    author: "coderabbitai".to_owned(),
6170                    path: Some("src/graph.rs".to_owned()),
6171                    line: Some(231),
6172                    body: format!("finding {i}"),
6173                })
6174                .collect(),
6175        }
6176    }
6177
6178    #[test]
6179    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6180        use CiExpectation::{Absent, Expected};
6181        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6182            let p = pr(checks, &["x"], 0);
6183            for waited in [Duration::ZERO, CHECKS_GRACE] {
6184                assert_eq!(
6185                    decide_with(&p, 0, 4, waited, Expected),
6186                    decide(&p, 0, 4, waited)
6187                );
6188            }
6189        }
6190        // Nothing will ever report: no wait, no give-up, no fix round.
6191        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6192            let p = pr(checks, &["x"], 0);
6193            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6194            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6195        }
6196        // A conflict is not a check: it still wants the rebase.
6197        let mut p = pr(Checks::Unknown, &[], 0);
6198        p.blocking = Blocking::Conflict;
6199        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6200        // And a pull request that left our hands is still done.
6201        p.state = PrLifecycle::Merged;
6202        assert_eq!(
6203            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6204            Step::Done { merged: true }
6205        );
6206    }
6207
6208    #[test]
6209    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6210        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6211        assert_eq!(state.number, 10);
6212        assert_eq!(state.state, PrLifecycle::Open);
6213        assert_eq!(state.checks, Checks::Green);
6214        assert!(state.failing.is_empty());
6215        assert!(
6216            state.review_comments.is_empty(),
6217            "the only comment is CodeRabbit's trigger notice: {:?}",
6218            state.review_comments
6219        );
6220        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6221    }
6222
6223    #[test]
6224    fn a_failing_check_parses_as_red_and_is_named() {
6225        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6226        assert_eq!(state.checks, Checks::Red);
6227        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6228        // The captured payload says `UNSTABLE` - mergeable, with a check
6229        // nobody requires red - which is exactly the shape that had to be
6230        // merged by hand. Asserted separately, in
6231        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6232        // test is about is that a red check is *named*, so the reason a fixer
6233        // is handed says which one; so it asks the blocking question here.
6234        let mut blocking = state.clone();
6235        blocking.blocking = Blocking::Yes;
6236        match decide(&blocking, 0, 4, Duration::ZERO) {
6237            Step::Fix { reason } => {
6238                assert!(reason.contains("editorconfig"), "reason: {reason}");
6239                assert!(reason.contains("failing"), "reason: {reason}");
6240            }
6241            other => panic!("expected a fix round, got {other:?}"),
6242        }
6243    }
6244
6245    #[test]
6246    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6247        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6248        assert_eq!(state.checks, Checks::Pending);
6249        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6250    }
6251
6252    #[test]
6253    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6254        let state = parse_pr(MERGED).expect("merged fixture parses");
6255        assert_eq!(state.state, PrLifecycle::Merged);
6256        assert_eq!(
6257            decide(&state, 0, 4, Duration::ZERO),
6258            Step::Done { merged: true }
6259        );
6260    }
6261
6262    #[test]
6263    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6264        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6265        assert_eq!(state.checks, Checks::Green);
6266        let authors: Vec<&str> = state
6267            .review_comments
6268            .iter()
6269            .map(|c| c.author.as_str())
6270            .collect();
6271        assert_eq!(
6272            authors,
6273            vec!["claude"],
6274            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6275        );
6276        match decide(&state, 0, 4, Duration::ZERO) {
6277            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6278            other => panic!("expected a fix round, got {other:?}"),
6279        }
6280    }
6281
6282    #[test]
6283    fn inline_review_comments_keep_their_file_and_line() {
6284        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6285        assert_eq!(comments.len(), 1);
6286        assert_eq!(comments[0].author, "claude[bot]");
6287        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6288        assert_eq!(comments[0].line, Some(231));
6289        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6290    }
6291
6292    #[test]
6293    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6294        assert!(
6295            is_noise(CODERABBIT_TRIGGER),
6296            "CodeRabbit's trigger notice declares itself not a review"
6297        );
6298        assert!(
6299            is_noise(CLAUDE_CHECKLIST),
6300            "a progress checklist asks for nothing"
6301        );
6302        assert!(
6303            !is_noise(CLAUDE_FINDING),
6304            "a review that names a bug is input, not noise"
6305        );
6306
6307        let mut clean = pr(Checks::Green, &[], 0);
6308        clean.review_comments.push(ReviewComment {
6309            author: "coderabbitai".to_owned(),
6310            path: None,
6311            line: None,
6312            body: CODERABBIT_TRIGGER.to_owned(),
6313        });
6314        clean.review_comments.retain(|c| !is_noise(&c.body));
6315        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6316
6317        let mut found = pr(Checks::Green, &[], 0);
6318        found.review_comments.push(ReviewComment {
6319            author: "claude".to_owned(),
6320            path: None,
6321            line: None,
6322            body: CLAUDE_FINDING.to_owned(),
6323        });
6324        found.review_comments.retain(|c| !is_noise(&c.body));
6325        assert!(matches!(
6326            decide(&found, 0, 4, Duration::ZERO),
6327            Step::Fix { .. }
6328        ));
6329    }
6330
6331    #[test]
6332    fn the_policy_table_holds_for_every_combination_that_matters() {
6333        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6334            (
6335                "pending checks are waited for, even on the last round",
6336                pr(Checks::Pending, &[], 0),
6337                4,
6338                4,
6339                Duration::ZERO,
6340                Step::Wait,
6341            ),
6342            (
6343                "red checks are fixed",
6344                pr(Checks::Red, &["editorconfig"], 0),
6345                0,
6346                4,
6347                Duration::ZERO,
6348                Step::Fix {
6349                    reason: "1 check(s) failing: editorconfig".to_owned(),
6350                },
6351            ),
6352            (
6353                "green with comments is fixed, not merged",
6354                pr(Checks::Green, &[], 2),
6355                1,
6356                4,
6357                Duration::ZERO,
6358                Step::Fix {
6359                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6360                        .to_owned(),
6361                },
6362            ),
6363            (
6364                "green and clean merges",
6365                pr(Checks::Green, &[], 0),
6366                3,
6367                4,
6368                Duration::ZERO,
6369                Step::Merge,
6370            ),
6371            (
6372                "an unreadable rollup is waited on while the grace lasts",
6373                pr(Checks::Unknown, &[], 0),
6374                0,
6375                4,
6376                Duration::ZERO,
6377                Step::Wait,
6378            ),
6379            (
6380                "an unreadable rollup is never merged once the grace is spent",
6381                pr(Checks::Unknown, &[], 0),
6382                0,
6383                4,
6384                CHECKS_GRACE,
6385                Step::GiveUp {
6386                    reason: "no check status is readable on the pull request after 3 minute(s); \
6387                             refusing to merge on a guess"
6388                        .to_owned(),
6389                },
6390            ),
6391        ];
6392        for (what, state, round, budget, waited, want) in cases {
6393            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6394        }
6395    }
6396
6397    #[test]
6398    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6399        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6400        // requested but never parsed is the kind of thing that looks wired up
6401        // and answers `Unsaid` forever.
6402        let green = parse_pr(GREEN_OPEN).expect("parse");
6403        assert_eq!(green.blocking, Blocking::No);
6404        let red = parse_pr(RED_OPEN).expect("parse");
6405        assert_eq!(
6406            red.blocking,
6407            Blocking::No,
6408            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6409        );
6410        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6411        // A payload from an older `gh` has no such field at all.
6412        let quiet =
6413            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6414        assert_eq!(quiet.blocking, Blocking::Unsaid);
6415    }
6416
6417    #[test]
6418    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6419        // Pull request 37's only red check was `editorconfig`, failing
6420        // because the action could not fetch its own binary after
6421        // editorconfig-checker v4 renamed its release assets. The repository
6422        // does not require it. magi answered by asking a fixer to repair a
6423        // change that was fine, and the pull request had to be merged by hand.
6424        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6425        nonblocking.blocking = Blocking::No;
6426        assert_eq!(
6427            decide(&nonblocking, 0, 4, Duration::ZERO),
6428            Step::Merge,
6429            "the forge says nothing is in the way, so nothing is"
6430        );
6431
6432        // The same red, gated on: that is a fix round, as before.
6433        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6434        blocking.blocking = Blocking::Yes;
6435        assert!(matches!(
6436            decide(&blocking, 0, 4, Duration::ZERO),
6437            Step::Fix { .. }
6438        ));
6439
6440        // A review comment still outranks green-enough: a non-required red
6441        // must not become a way to merge past an unanswered reviewer.
6442        let mut commented = pr(Checks::Red, &["coverage"], 1);
6443        commented.blocking = Blocking::No;
6444        assert!(matches!(
6445            decide(&commented, 0, 4, Duration::ZERO),
6446            Step::Fix { .. }
6447        ));
6448
6449        // And silence from the forge is not consent.
6450        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6451        unsaid.blocking = Blocking::Unsaid;
6452        assert!(matches!(
6453            decide(&unsaid, 0, 4, Duration::ZERO),
6454            Step::Fix { .. }
6455        ));
6456    }
6457
6458    #[test]
6459    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6460        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6461        red.blocking = Blocking::No;
6462        assert_eq!(
6463            decide(&red, 0, 4, Duration::ZERO),
6464            Step::Merge,
6465            "announcing must not change the decision"
6466        );
6467        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6468        assert!(said.contains("yukimemi/magi"), "{said}");
6469        assert!(said.contains("#16"), "{said}");
6470        assert!(
6471            said.contains("https://github.com/yukimemi/magi/pull/16"),
6472            "{said}"
6473        );
6474        assert!(
6475            said.contains("test (windows-latest)") && said.contains("coverage"),
6476            "{said}"
6477        );
6478
6479        // `failing` can be left over on a green observation; only `checks` counts.
6480        let green = pr(Checks::Green, &["stale"], 0);
6481        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6482    }
6483
6484    #[test]
6485    fn red_merge_notices_omit_empty_check_details() {
6486        for checks in [vec![], vec!["", "  "]] {
6487            let red = pr(Checks::Red, &checks, 0);
6488            let summary = red_merge_summary("owner/repo", &red).unwrap();
6489            let notice = crate::notices::merged_red("run-1", &summary);
6490            assert_eq!(
6491                notice.message,
6492                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6493            );
6494            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6495        }
6496        let red = pr(Checks::Red, &["", " test ", " "], 0);
6497        let summary = red_merge_summary("owner/repo", &red).unwrap();
6498        assert!(summary.contains("with red checks: test ("), "{summary}");
6499    }
6500
6501    #[test]
6502    fn the_repo_label_comes_from_the_pull_request_url() {
6503        let p = Path::new("/tmp/checkout");
6504        assert_eq!(
6505            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6506            "yukimemi/magi"
6507        );
6508        assert_eq!(repo_label(p, "not a url"), "checkout");
6509    }
6510
6511    #[test]
6512    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6513        // Pull requests 35 and 37 were both rebased by hand: a competition
6514        // that runs for two hours against a repository merging pull requests
6515        // all day conflicts on the way in, and that is arithmetic rather
6516        // than a defect in the change.
6517        let mut conflicted = pr(Checks::Green, &[], 0);
6518        conflicted.blocking = Blocking::Conflict;
6519        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6520
6521        // Decided before the checks, and even with the rounds spent: every
6522        // check on a branch that cannot land is an answer about a state that
6523        // cannot land, and a conflict is not the change's fault.
6524        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6525        red.blocking = Blocking::Conflict;
6526        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6527
6528        // The lifecycle still wins over everything, conflict included.
6529        let mut merged = pr(Checks::Red, &[], 0);
6530        merged.blocking = Blocking::Conflict;
6531        merged.state = PrLifecycle::Merged;
6532        assert_eq!(
6533            decide(&merged, 0, 4, Duration::ZERO),
6534            Step::Done { merged: true }
6535        );
6536    }
6537
6538    #[test]
6539    fn the_forge_verdict_is_read_off_merge_state_status() {
6540        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6541        // matters: mergeable, with a non-required check red or still running.
6542        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6543            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6544            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6545        }
6546        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6547        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6548        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6549        // An older `gh`, or a token without the scope, says nothing - and
6550        // refusing to guess is the rule everywhere else in this module.
6551        for quiet in ["", "UNKNOWN"] {
6552            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6553            assert!(Blocking::of(quiet).stops_a_merge());
6554        }
6555    }
6556
6557    #[test]
6558    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6559        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6560        // The exact stderr from run ec12, in a jj-colocated repository.
6561        let jj = "could not determine current branch: failed to run git: not on any branch";
6562
6563        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6564            .expect("the forge says merged, so it merged");
6565        assert!(landed.ok);
6566        assert!(
6567            landed.detail.contains("but the pull request is merged"),
6568            "the record must not read as a clean success: {}",
6569            landed.detail
6570        );
6571        assert!(
6572            landed.detail.contains("not on any branch"),
6573            "and it must keep what the command actually said: {}",
6574            landed.detail
6575        );
6576
6577        // A pull request still open means the merge really failed.
6578        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6579        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6580        // And an unreadable answer is not evidence of success.
6581        assert!(merged_after_all(&argv, jj, None).is_none());
6582    }
6583
6584    #[test]
6585    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6586        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6587        state.state = PrLifecycle::Closed;
6588        assert_eq!(
6589            decide(&state, 0, 4, Duration::ZERO),
6590            Step::Done { merged: false },
6591            "a human closing the pull request ends the loop, whatever CI says"
6592        );
6593    }
6594
6595    #[test]
6596    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6597        let red = decide(
6598            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6599            4,
6600            4,
6601            Duration::ZERO,
6602        );
6603        match red {
6604            Step::GiveUp { reason } => {
6605                assert!(reason.contains("editorconfig"), "reason: {reason}");
6606                assert!(reason.contains("test (macos)"), "reason: {reason}");
6607                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6608            }
6609            other => panic!("expected a give-up, got {other:?}"),
6610        }
6611
6612        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6613        match commented {
6614            Step::GiveUp { reason } => {
6615                assert!(reason.contains("unresolved"), "reason: {reason}");
6616                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6617            }
6618            other => panic!("expected a give-up, got {other:?}"),
6619        }
6620    }
6621
6622    #[test]
6623    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6624        let candidate_commit = "magi: candidate A (uncommitted work)";
6625        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6626        let argv = merge_argv(16, &subject);
6627
6628        assert!(argv.contains(&"--squash".to_owned()));
6629        assert!(argv.contains(&"--delete-branch".to_owned()));
6630        assert!(argv.contains(&"--subject".to_owned()));
6631        assert_eq!(
6632            argv.last().map(String::as_str),
6633            Some("add retries to the uploader"),
6634            "the subject must not be the candidate commit message"
6635        );
6636        assert_ne!(subject, candidate_commit);
6637    }
6638
6639    #[test]
6640    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
6641        assert_eq!(
6642            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
6643            "feat: a queue, an unattended loop, and a phone UI"
6644        );
6645        assert_eq!(
6646            merge_subject("", "# port the retry logic\n\ndetails"),
6647            "port the retry logic",
6648            "an empty title falls back to the task's first line, heading marks stripped"
6649        );
6650    }
6651
6652    #[test]
6653    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
6654        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
6655        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
6656        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
6657        assert_eq!(job_of("https://coderabbit.ai/status"), None);
6658        assert_eq!(run_of(""), None);
6659    }
6660
6661    #[test]
6662    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
6663        let mut out = Vec::new();
6664        push_if_outstanding(
6665            &mut out,
6666            ReviewComment {
6667                author: "yukimemi".to_owned(),
6668                path: None,
6669                line: None,
6670                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
6671            },
6672        );
6673        assert!(out.is_empty());
6674    }
6675
6676    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
6677    /// falls back to the repository - which keeps these tests free of a
6678    /// worktree, a `git` invocation and a network.
6679    fn run_state() -> RunState {
6680        let mut state = RunState::new(
6681            std::path::PathBuf::from("/repo/magi"),
6682            "main".to_owned(),
6683            "abcdef1234".to_owned(),
6684            "add retries to the uploader".to_owned(),
6685            crate::config::Config::default(),
6686        );
6687        // Tests run in parallel against one persistent home and the ids
6688        // `RunState::new` draws from the clock can repeat, so two tests would
6689        // share a run's questions. The home also outlives the process, so the
6690        // counter alone would reuse an earlier run's ids.
6691        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
6692        let nanos = std::time::SystemTime::now()
6693            .duration_since(std::time::UNIX_EPOCH)
6694            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
6695        state.id = format!(
6696            "20261004-{nanos:06}-{:04x}",
6697            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
6698        );
6699        state
6700    }
6701
6702    fn green_pr() -> PrState {
6703        PrState {
6704            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
6705            number: 42,
6706            state: PrLifecycle::Open,
6707            checks: Checks::Green,
6708            // The forge sees nothing in the way unless a test says otherwise.
6709            blocking: Blocking::No,
6710            failing: Vec::new(),
6711            review_comments: vec![ReviewComment {
6712                author: "coderabbitai".to_owned(),
6713                path: Some("src/land.rs".to_owned()),
6714                line: Some(212),
6715                body: "this branch never checks the exit code".to_owned(),
6716            }],
6717        }
6718    }
6719
6720    #[test]
6721    fn land_fixer_defers_review_and_verification_without_a_cache() {
6722        let state = run_state();
6723        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6724        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
6725        assert!(p.contains("Commit in this worktree"), "{p}");
6726    }
6727
6728    #[test]
6729    fn github_facing_land_text_is_english_whatever_the_language() {
6730        let mut state = run_state();
6731        state.config.graph.language = "ja".to_owned();
6732        let comment = stop_comment(&state.id, "checks are still red");
6733        assert!(crate::github_text::check("", &comment).is_empty());
6734        assert!(comment.is_ascii(), "{comment}");
6735        assert!(comment.starts_with(MARKER));
6736
6737        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6738        let ja_at = p.find("Write all prose in ja").unwrap();
6739        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
6740        assert!(ja_at < rule_at, "{p}");
6741        assert!(p.contains("stays in Japanese"), "{p}");
6742
6743        state.config.graph.language = "en".to_owned();
6744        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
6745        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
6746        assert!(!p.contains("does not apply"), "{p}");
6747    }
6748
6749    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
6750
6751    fn panel() -> String {
6752        approval_panel(
6753            &run_state(),
6754            &green_pr(),
6755            NUMSTAT,
6756            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
6757            &[
6758                "land: ask before merging".to_owned(),
6759                "land: colour the diff".to_owned(),
6760            ],
6761            "feat: merge approval from the phone",
6762        )
6763    }
6764
6765    #[test]
6766    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
6767        let html = panel();
6768        for needle in [
6769            "42",
6770            "main",
6771            "src/land.rs",
6772            "src/web.rs",
6773            "assets/logo.png",
6774            "feat: merge approval from the phone",
6775            "land: ask before merging",
6776            "land: colour the diff",
6777            "coderabbitai",
6778            "this branch never checks the exit code",
6779            "green",
6780        ] {
6781            assert!(html.contains(needle), "the panel must state `{needle}`");
6782        }
6783    }
6784
6785    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
6786        crate::run::pin_test_home();
6787        let tmp = tempfile::tempdir().unwrap();
6788        let repo = tmp.path().join("repo");
6789        let remote = tmp.path().join("remote.git");
6790        std::fs::create_dir(&repo).unwrap();
6791        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
6792            .await
6793            .unwrap();
6794        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
6795        git::git(&repo, &["config", "user.name", "Test"])
6796            .await
6797            .unwrap();
6798        git::git(&repo, &["config", "user.email", "test@example.test"])
6799            .await
6800            .unwrap();
6801        std::fs::write(repo.join("base"), "base").unwrap();
6802        git::git(&repo, &["add", "."]).await.unwrap();
6803        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
6804        git::git(
6805            &repo,
6806            &["remote", "add", "origin", remote.to_str().unwrap()],
6807        )
6808        .await
6809        .unwrap();
6810        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
6811        git::git(&repo, &["checkout", "-b", "winner"])
6812            .await
6813            .unwrap();
6814        std::fs::write(repo.join("change"), "change").unwrap();
6815        git::git(&repo, &["add", "."]).await.unwrap();
6816        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
6817        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
6818        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
6819            .await
6820            .unwrap();
6821        let mut state = run_state();
6822        state.repo = repo.clone();
6823        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
6824        state.config.graph.land = true;
6825        state.config.graph.land_approval = true;
6826        state.pr_head = Some("task-head".into());
6827        state.pr_pushed_head = Some(head.clone());
6828        state.candidates = vec![winning_candidate("change")];
6829        state.candidates[0].worktree = repo;
6830        state.candidates[0].branch = "winner".into();
6831        state.tally = Some(uncontested_tally());
6832        state.gate_ran = true;
6833        state.gate_head = Some(head.clone());
6834        state.land_sync_pending = true;
6835        state.save().unwrap();
6836        (tmp, state, head)
6837    }
6838
6839    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
6840        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
6841        resume_landing_head(state, head, &green_pr().url, &forge).await
6842    }
6843
6844    async fn advance_landing_base(state: &RunState) {
6845        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
6846        std::fs::write(state.repo.join("advance"), "advance").unwrap();
6847        git::git(&state.repo, &["add", "."]).await.unwrap();
6848        git::git(&state.repo, &["commit", "-m", "advance base"])
6849            .await
6850            .unwrap();
6851        git::git(&state.repo, &["push", "origin", "main"])
6852            .await
6853            .unwrap();
6854        git::git(&state.repo, &["checkout", "winner"])
6855            .await
6856            .unwrap();
6857    }
6858
6859    #[tokio::test]
6860    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
6861        let (_tmp, mut state, old) = landing_git_fixture().await;
6862        advance_landing_base(&state).await;
6863        state.config.verify.gate = vec!["test -f advance".into()];
6864        let head = resume_observed_head(&mut state, &old).await.unwrap();
6865        assert_ne!(head, old);
6866        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
6867        assert!(state.gate_allows_progress());
6868        assert_eq!(
6869            git::remote_head(&state.repo, "origin", "task-head")
6870                .await
6871                .unwrap(),
6872            Some(head.clone())
6873        );
6874        assert_eq!(state.pr_pushed_head, Some(head));
6875        assert!(!state.land_sync_pending);
6876        assert!(state.config.graph.land_approval);
6877    }
6878
6879    fn landing_gate_fixer(state: &mut RunState, script: &str) {
6880        state.config.disk.min_free_bytes = 0;
6881        state.config.graph.gate_fix_rounds = 1;
6882        state.config.agents = vec![crate::config::AgentSpec {
6883            id: "mock".into(),
6884            kind: crate::config::AgentKind::Command,
6885            model: None,
6886            command: vec!["sh".into(), "-c".into(), script.into()],
6887            extra_args: Vec::new(),
6888            env: Default::default(),
6889            prompt_delivery: None,
6890        }];
6891        state.candidates[0].agent = "mock".into();
6892    }
6893
6894    #[tokio::test]
6895    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
6896        let (_tmp, mut state, old) = landing_git_fixture().await;
6897        std::fs::write(state.repo.join("change"), "operator update").unwrap();
6898        git::commit_all(&state.repo, "extend winner").await.unwrap();
6899        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6900        git::git(
6901            &state.repo,
6902            &["push", "origin", "HEAD:refs/heads/task-head"],
6903        )
6904        .await
6905        .unwrap();
6906        state.config.verify.gate = vec!["exit 1".into()];
6907        state.config.graph.gate_fix_rounds = 0;
6908        assert!(resume_observed_head(&mut state, &updated).await.is_err());
6909        assert_ne!(old, updated);
6910        assert_eq!(state.gate_head.as_ref(), Some(&updated));
6911        assert!(!state.gate_allows_progress());
6912        assert!(state.land_sync_pending);
6913        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
6914        let head = resume_observed_head(&mut state, &updated).await.unwrap();
6915        assert_eq!(head, updated);
6916        assert!(state.gate_allows_progress());
6917        assert!(!state.land_sync_pending);
6918        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
6919        land_with(&mut state, &green_pr().url, &forge)
6920            .await
6921            .unwrap();
6922        assert!(state.parked);
6923        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
6924        assert!(forge.argvs().is_empty());
6925    }
6926
6927    #[tokio::test]
6928    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
6929        for divergent in [false, true] {
6930            let (_tmp, mut state, old) = landing_git_fixture().await;
6931            if divergent {
6932                git::git(&state.repo, &["reset", "--hard", "origin/main"])
6933                    .await
6934                    .unwrap();
6935            }
6936            std::fs::write(state.repo.join("external"), "external").unwrap();
6937            git::commit_all(&state.repo, "external update")
6938                .await
6939                .unwrap();
6940            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
6941            git::git(
6942                &state.repo,
6943                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
6944            )
6945            .await
6946            .unwrap();
6947            if !divergent {
6948                git::git(&state.repo, &["reset", "--hard", &old])
6949                    .await
6950                    .unwrap();
6951            }
6952            let error = resume_observed_head(&mut state, &external)
6953                .await
6954                .unwrap_err();
6955            assert!(
6956                error
6957                    .to_string()
6958                    .contains("public head moved outside this run")
6959            );
6960            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
6961            assert_eq!(
6962                git::remote_head(&state.repo, "origin", "task-head")
6963                    .await
6964                    .unwrap(),
6965                Some(external)
6966            );
6967        }
6968    }
6969
6970    #[tokio::test]
6971    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
6972        let (_tmp, mut state, old) = landing_git_fixture().await;
6973        advance_landing_base(&state).await;
6974        landing_gate_fixer(
6975            &mut state,
6976            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
6977        );
6978        state.config.verify.gate =
6979            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
6980        state.config.verify.e2e = vec!["test -f advance".into()];
6981        // First save a red gate without spending a fixer round. Resume must
6982        // still offer the remaining budget for this exact same failed head.
6983        state.config.graph.gate_fix_rounds = 0;
6984        assert!(resume_observed_head(&mut state, &old).await.is_err());
6985        assert_eq!(
6986            git::remote_head(&state.repo, "origin", "task-head")
6987                .await
6988                .unwrap(),
6989            Some(old.clone())
6990        );
6991        state.config.graph.gate_fix_rounds = 1;
6992        let head = resume_observed_head(&mut state, &old).await.unwrap();
6993        assert_eq!(state.gate_fixes.len(), 1);
6994        assert!(state.gate_fixes[0].committed);
6995        assert!(state.gate_allows_progress());
6996        assert_eq!(state.gate_head.as_ref(), Some(&head));
6997        assert_eq!(
6998            git::remote_head(&state.repo, "origin", "task-head")
6999                .await
7000                .unwrap(),
7001            Some(head.clone())
7002        );
7003        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7004        land_with(&mut state, &green_pr().url, &forge)
7005            .await
7006            .unwrap();
7007        assert!(state.parked);
7008        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7009        assert!(forge.argvs().is_empty());
7010    }
7011
7012    #[tokio::test]
7013    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7014        let (_tmp, mut state, old) = landing_git_fixture().await;
7015        advance_landing_base(&state).await;
7016        landing_gate_fixer(
7017            &mut state,
7018            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7019        );
7020        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7021        for _ in 0..2 {
7022            assert!(resume_observed_head(&mut state, &old).await.is_err());
7023            assert_eq!(state.gate_fixes.len(), 1);
7024            assert_eq!(
7025                git::remote_head(&state.repo, "origin", "task-head")
7026                    .await
7027                    .unwrap(),
7028                Some(old.clone())
7029            );
7030        }
7031    }
7032
7033    #[tokio::test]
7034    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7035        let (_tmp, mut state, old) = landing_git_fixture().await;
7036        advance_landing_base(&state).await;
7037        landing_gate_fixer(
7038            &mut state,
7039            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7040        );
7041        state.config.verify.gate =
7042            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7043        // The repair makes the gate green while breaking an e2e invariant.
7044        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7045        for resume in 0..3 {
7046            assert!(resume_observed_head(&mut state, &old).await.is_err());
7047            state = RunState::load(&state.id).unwrap();
7048            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7049            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7050            assert_eq!(evidence.head, head);
7051            assert!(!evidence.passed);
7052            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7053            assert_eq!(state.gate_fixes.len(), 1);
7054            assert_eq!(
7055                git::remote_head(&state.repo, "origin", "task-head")
7056                    .await
7057                    .unwrap(),
7058                Some(old.clone())
7059            );
7060            if resume > 0 {
7061                assert!(
7062                    state.gate_allows_progress(),
7063                    "a green gate must not bypass red e2e"
7064                );
7065            }
7066        }
7067        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7068        // can be satisfied without another fixer or approval bypass.
7069        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7070        let head = resume_observed_head(&mut state, &old).await.unwrap();
7071        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7072        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7073        assert_eq!(state.gate_fixes.len(), 1);
7074        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7075        land_with(&mut state, &green_pr().url, &forge)
7076            .await
7077            .unwrap();
7078        assert!(state.parked);
7079        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7080        assert!(forge.argvs().is_empty());
7081    }
7082
7083    #[tokio::test]
7084    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7085        for phase in 0..3 {
7086            let (_tmp, mut state, old) = landing_git_fixture().await;
7087            advance_landing_base(&state).await;
7088            let head = resume_observed_head(&mut state, &old).await.unwrap();
7089            // Persist the shapes left by interruption, resource deferral,
7090            // and a successful check for a different (older) SHA.
7091            state.land_sync_pending = true;
7092            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7093            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7094                head: if phase == 2 {
7095                    old.clone()
7096                } else {
7097                    head.clone()
7098                },
7099                passed: phase == 2,
7100                outcomes: if phase == 1 {
7101                    vec![crate::run::CommandOutcome {
7102                        command: state.config.verify.e2e[0].clone(),
7103                        code: None,
7104                        output_tail: "verification resource unavailable".into(),
7105                        duration_ms: 0,
7106                        resource_blocked: true,
7107                    }]
7108                } else {
7109                    Vec::new()
7110                },
7111            });
7112            state.save().unwrap();
7113            for _ in 0..2 {
7114                state = RunState::load(&state.id).unwrap();
7115                assert!(resume_observed_head(&mut state, &head).await.is_err());
7116                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7117                assert_eq!(evidence.head, head);
7118                assert!(!evidence.passed);
7119                assert!(!evidence.outcomes.is_empty());
7120                assert_eq!(
7121                    git::remote_head(&state.repo, "origin", "task-head")
7122                        .await
7123                        .unwrap(),
7124                    Some(head.clone())
7125                );
7126                assert!(state.land_sync_pending);
7127            }
7128        }
7129    }
7130
7131    async fn interrupt_landing_rebase(state: &mut RunState) {
7132        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7133        state.save().unwrap();
7134        let scratch = state.dir().join("rebase");
7135        assert_eq!(
7136            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7137                .await
7138                .unwrap(),
7139            crate::rebase::Rebased::Applied
7140        );
7141        assert!(!git::is_clean(&state.repo).await.unwrap());
7142    }
7143
7144    async fn landing_setup(state: &mut RunState) {
7145        git::git(
7146            &state.repo,
7147            &["config", "extensions.worktreeConfig", "true"],
7148        )
7149        .await
7150        .unwrap();
7151        state.config.worktree.setup =
7152            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7153        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7154            .await
7155            .unwrap();
7156        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7157    }
7158
7159    #[tokio::test]
7160    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7161        for phase in 0..3 {
7162            let (_tmp, mut state, old) = landing_git_fixture().await;
7163            advance_landing_base(&state).await;
7164            landing_setup(&mut state).await;
7165            if phase > 0 {
7166                interrupt_landing_rebase(&mut state).await;
7167                if phase == 2 {
7168                    // Crash after clean removed setup products, before setup.
7169                    git::sync_to_head(&state.repo).await.unwrap();
7170                    assert!(!state.repo.join(".env").exists());
7171                }
7172                state = RunState::load(&state.id).unwrap();
7173            }
7174            let head = resume_observed_head(&mut state, &old).await.unwrap();
7175            assert_ne!(head, old);
7176            assert!(state.gate_allows_progress());
7177            assert!(state.land_rebase_from.is_none());
7178            assert_eq!(
7179                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7180                "secret"
7181            );
7182            assert!(git::is_clean(&state.repo).await.unwrap());
7183            assert!(
7184                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7185                    .await
7186                    .unwrap()
7187                    .contains(".env")
7188            );
7189        }
7190    }
7191
7192    #[tokio::test]
7193    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7194        let (_tmp, mut state, _) = landing_git_fixture().await;
7195        advance_landing_base(&state).await;
7196        landing_setup(&mut state).await;
7197        interrupt_landing_rebase(&mut state).await;
7198        // Exercise unusual names without asking Windows to create an invalid
7199        // control-character filename. Unix also covers embedded newlines.
7200        let locals = [
7201            "local notes",
7202            #[cfg(unix)]
7203            "local\nnotes",
7204        ];
7205        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7206            .await
7207            .unwrap();
7208        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7209        for local in locals {
7210            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7211            assert!(
7212                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7213                    .await
7214                    .unwrap()
7215                    .ok()
7216            );
7217        }
7218        restore_landing_worktree(&mut state).await.unwrap();
7219        for local in locals {
7220            assert_eq!(
7221                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7222                "keep these notes"
7223            );
7224        }
7225        assert_eq!(
7226            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7227            "secret"
7228        );
7229        assert!(git::is_clean(&state.repo).await.unwrap());
7230        assert!(state.land_rebase_from.is_none());
7231    }
7232
7233    #[tokio::test]
7234    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7235        for local in ["advance", "advance/notes", "new-directory"] {
7236            let (_tmp, mut state, _) = landing_git_fixture().await;
7237            advance_landing_base(&state).await;
7238            // Also cover a local file blocking a new tracked directory.
7239            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7240            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7241            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7242            git::git(&state.repo, &["add", "."]).await.unwrap();
7243            git::git(&state.repo, &["commit", "-m", "add directory"])
7244                .await
7245                .unwrap();
7246            git::git(&state.repo, &["push", "origin", "main"])
7247                .await
7248                .unwrap();
7249            git::git(&state.repo, &["checkout", "winner"])
7250                .await
7251                .unwrap();
7252            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7253                .await
7254                .unwrap();
7255            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7256            let path = state.repo.join(local);
7257            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7258            std::fs::write(&path, "local notes").unwrap();
7259            interrupt_landing_rebase(&mut state).await;
7260            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7261            let checkpoint = state.land_rebase_from.clone();
7262            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7263            assert!(
7264                error.to_string().contains("conflicting with HEAD"),
7265                "{error:#}"
7266            );
7267            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7268            assert_eq!(
7269                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7270                before
7271            );
7272            assert_eq!(state.land_rebase_from, checkpoint);
7273        }
7274    }
7275
7276    #[tokio::test]
7277    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7278        for interrupted in [false, true] {
7279            let (_tmp, mut state, old) = landing_git_fixture().await;
7280            assert!(state.config.worktree.setup.is_empty());
7281            // A gate product exists before the base moves, without setup metadata.
7282            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7283                .await
7284                .unwrap();
7285            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7286            state.config.verify.gate =
7287                vec!["mkdir -p target && printf artifact > target/probe".into()];
7288            state.gate_ran = false;
7289            resume_observed_head(&mut state, &old).await.unwrap();
7290            assert_eq!(
7291                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7292                "artifact"
7293            );
7294            advance_landing_base(&state).await;
7295            if interrupted {
7296                interrupt_landing_rebase(&mut state).await;
7297                state = RunState::load(&state.id).unwrap();
7298            }
7299            state.config.verify.gate =
7300                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7301            let head = resume_observed_head(&mut state, &old).await.unwrap();
7302            assert_ne!(head, old);
7303            assert!(state.gate_allows_progress());
7304            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7305            assert_eq!(
7306                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7307                "artifact"
7308            );
7309            assert!(state.land_rebase_from.is_none());
7310            assert_eq!(
7311                git::remote_head(&state.repo, "origin", "task-head")
7312                    .await
7313                    .unwrap(),
7314                Some(head.clone())
7315            );
7316            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7317            land_with(&mut state, &green_pr().url, &forge)
7318                .await
7319                .unwrap();
7320            assert!(state.parked);
7321            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7322            assert!(forge.argvs().is_empty());
7323        }
7324    }
7325
7326    #[tokio::test]
7327    #[cfg(unix)]
7328    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7329        use std::os::fd::AsRawFd as _;
7330
7331        for interrupted in [false, true] {
7332            let (_tmp, mut state, _) = landing_git_fixture().await;
7333            advance_landing_base(&state).await;
7334            landing_setup(&mut state).await;
7335            if interrupted {
7336                interrupt_landing_rebase(&mut state).await;
7337            }
7338            state.config.worktree.setup =
7339                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7340            let common = crate::repo_lock::common_dir(&state.repo)
7341                .await
7342                .unwrap()
7343                .unwrap();
7344            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7345            state.config.coordination = crate::config::test_coordination();
7346            state.config.graph.timeout_resource_wait = 0;
7347            let external = std::fs::OpenOptions::new()
7348                .read(true)
7349                .write(true)
7350                .create(true)
7351                .truncate(false)
7352                .open(common.join("updater.lock"))
7353                .unwrap();
7354            // SAFETY: the descriptor belongs to `external` and remains open.
7355            let locked =
7356                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7357            assert_eq!(locked, 0);
7358            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7359            let checkpoint = state.land_rebase_from.clone();
7360            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7361            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7362            assert_eq!(
7363                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7364                before
7365            );
7366            assert_eq!(state.land_rebase_from, checkpoint);
7367            assert_eq!(
7368                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7369                "secret"
7370            );
7371
7372            drop(external);
7373            state.config.graph.timeout_resource_wait = 2;
7374            restore_landing_worktree(&mut state).await.unwrap();
7375            assert!(git::is_clean(&state.repo).await.unwrap());
7376            assert!(state.land_rebase_from.is_none());
7377            assert_eq!(
7378                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7379                "restored"
7380            );
7381        }
7382    }
7383
7384    #[tokio::test]
7385    #[cfg(unix)]
7386    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7387        use std::sync::atomic::{AtomicBool, Ordering};
7388
7389        let (_tmp, mut state, _) = landing_git_fixture().await;
7390        advance_landing_base(&state).await;
7391        landing_setup(&mut state).await;
7392        interrupt_landing_rebase(&mut state).await;
7393        let common = crate::repo_lock::common_dir(&state.repo)
7394            .await
7395            .unwrap()
7396            .unwrap();
7397        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7398        state.config.coordination = crate::config::test_coordination();
7399        let holder = crate::repo_lock::SeatLease::for_checkout(
7400            &state.repo,
7401            true,
7402            state.config.coordination.as_ref(),
7403        )
7404        .await
7405        .unwrap();
7406        assert!(holder.is_some());
7407        let cancelled = Arc::new(AtomicBool::new(false));
7408        let signal = cancelled.clone();
7409        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7410            signal.load(Ordering::SeqCst)
7411        });
7412        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7413        let checkpoint = state.land_rebase_from.clone();
7414        let (result, ()) = tokio::join!(
7415            tokio::time::timeout(
7416                Duration::from_secs(2),
7417                wait.scope(restore_landing_worktree(&mut state)),
7418            ),
7419            async {
7420                tokio::time::sleep(Duration::from_millis(100)).await;
7421                cancelled.store(true, Ordering::SeqCst);
7422            }
7423        );
7424        let error = result
7425            .expect("cancellation must release the waiting driver")
7426            .unwrap_err();
7427        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7428        assert_eq!(
7429            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7430            before
7431        );
7432        assert_eq!(state.land_rebase_from, checkpoint);
7433        assert_eq!(
7434            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7435            "secret"
7436        );
7437        drop(holder);
7438        restore_landing_worktree(&mut state).await.unwrap();
7439        assert!(state.land_rebase_from.is_none());
7440        assert!(git::is_clean(&state.repo).await.unwrap());
7441    }
7442
7443    #[tokio::test]
7444    #[cfg(unix)]
7445    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7446        use std::sync::atomic::{AtomicBool, Ordering};
7447
7448        for e2e in [false, true] {
7449            let (_tmp, mut state, head) = landing_git_fixture().await;
7450            let common = crate::repo_lock::common_dir(&state.repo)
7451                .await
7452                .unwrap()
7453                .unwrap();
7454            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7455            state.config.coordination = crate::config::test_coordination();
7456            if e2e {
7457                state.config.verify.e2e = vec!["true".into()];
7458                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7459                    head: head.clone(),
7460                    passed: false,
7461                    outcomes: Vec::new(),
7462                });
7463            } else {
7464                state.config.verify.gate = vec!["true".into()];
7465                state.gate_ran = false;
7466            }
7467            let holder = crate::repo_lock::SeatLease::for_checkout(
7468                &state.repo,
7469                true,
7470                state.config.coordination.as_ref(),
7471            )
7472            .await
7473            .unwrap();
7474            assert!(holder.is_some());
7475            let cancelled = Arc::new(AtomicBool::new(false));
7476            let signal = cancelled.clone();
7477            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7478                signal.load(Ordering::SeqCst)
7479            });
7480            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7481            let url = green_pr().url;
7482            let (result, ()) = tokio::join!(
7483                tokio::time::timeout(
7484                    Duration::from_secs(2),
7485                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7486                ),
7487                async {
7488                    tokio::time::sleep(Duration::from_millis(100)).await;
7489                    cancelled.store(true, Ordering::SeqCst);
7490                }
7491            );
7492            let error = result
7493                .expect("cancellation must release the verification waiter")
7494                .unwrap_err();
7495            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7496            assert!(forge.argvs().is_empty());
7497            assert_eq!(
7498                git::remote_head(&state.repo, "origin", "task-head")
7499                    .await
7500                    .unwrap(),
7501                Some(head.clone())
7502            );
7503            drop(holder);
7504            // Cancellation must release its reservation so another attempt can verify.
7505            assert_eq!(
7506                publish_landing_head(&mut state, &head, &url, &forge)
7507                    .await
7508                    .unwrap(),
7509                head
7510            );
7511        }
7512    }
7513
7514    #[tokio::test]
7515    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7516        for staged in [false, true] {
7517            let (_tmp, mut state, old) = landing_git_fixture().await;
7518            advance_landing_base(&state).await;
7519            interrupt_landing_rebase(&mut state).await;
7520            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7521            if staged {
7522                git::git(&state.repo, &["add", "change"]).await.unwrap();
7523            }
7524            assert!(resume_observed_head(&mut state, &old).await.is_err());
7525            assert_eq!(
7526                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7527                "user edit"
7528            );
7529            assert_eq!(
7530                git::remote_head(&state.repo, "origin", "task-head")
7531                    .await
7532                    .unwrap(),
7533                Some(old)
7534            );
7535        }
7536    }
7537
7538    #[tokio::test]
7539    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7540        let (_tmp, mut state, head) = landing_git_fixture().await;
7541        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7542        let id = state.id.clone();
7543        let pr = land_with(&mut state, &green_pr().url, &forge)
7544            .await
7545            .unwrap();
7546        assert_eq!(pr.state, PrLifecycle::Open);
7547        assert_eq!(state.id, id);
7548        assert!(state.parked);
7549        assert_eq!(state.status, RunStatus::Landing);
7550        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7551        assert!(forge.argvs().is_empty());
7552
7553        state.land_sync_pending = true;
7554        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7555        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7556        closed.pr.state = PrLifecycle::Closed;
7557        let forge = Scripted::new(vec![closed], vec![]);
7558        assert_eq!(
7559            land_with(&mut state, &green_pr().url, &forge)
7560                .await
7561                .unwrap()
7562                .state,
7563            PrLifecycle::Closed
7564        );
7565        assert_eq!(
7566            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7567            "do not touch"
7568        );
7569        assert!(forge.argvs().is_empty());
7570    }
7571
7572    #[tokio::test]
7573    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7574        let (_tmp, mut state, old) = landing_git_fixture().await;
7575        advance_landing_base(&state).await;
7576        state.config.verify.gate = vec!["exit 1".into()];
7577        assert!(resume_observed_head(&mut state, &old).await.is_err());
7578        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7579        assert!(!state.gate_allows_progress());
7580        assert_eq!(
7581            git::remote_head(&state.repo, "origin", "task-head")
7582                .await
7583                .unwrap(),
7584            Some(old)
7585        );
7586        assert!(state.land_sync_pending);
7587    }
7588
7589    #[tokio::test]
7590    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7591        let (_tmp, mut state, old) = landing_git_fixture().await;
7592        advance_landing_base(&state).await;
7593        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7594        // Model a crash after the push but before saving its published SHA.
7595        state.pr_pushed_head = Some(old.clone());
7596        state.land_sync_pending = true;
7597        assert_eq!(
7598            resume_observed_head(&mut state, &pushed).await.unwrap(),
7599            pushed
7600        );
7601        // A writer races the observed PR snapshot: the explicit remote lease
7602        // must reject the push even though preflight saw our expected head.
7603        git::git(
7604            &state.repo,
7605            &["push", "--force", "origin", "main:refs/heads/task-head"],
7606        )
7607        .await
7608        .unwrap();
7609        assert!(
7610            publish_landing_head(
7611                &mut state,
7612                &pushed,
7613                &green_pr().url,
7614                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7615            )
7616            .await
7617            .unwrap_err()
7618            .to_string()
7619            .contains("publishing gated")
7620        );
7621        assert!(
7622            resume_observed_head(&mut state, "foreign-head")
7623                .await
7624                .is_err()
7625        );
7626    }
7627    #[tokio::test]
7628    async fn landing_publication_refuses_a_pr_closed_during_gate() {
7629        let (_tmp, mut state, head) = landing_git_fixture().await;
7630        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
7631        closed.pr.state = PrLifecycle::Closed;
7632        let forge = Scripted::new(vec![closed], vec![]);
7633        assert!(
7634            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
7635                .await
7636                .unwrap_err()
7637                .to_string()
7638                .contains("PR closed or merged")
7639        );
7640    }
7641
7642    /// A candidate selected by the uncontested tally.
7643    fn winning_candidate(summary: &str) -> Candidate {
7644        Candidate {
7645            index: 0,
7646            label: 'A',
7647            agent: "opus".to_owned(),
7648            branch: "magi/x/A".to_owned(),
7649            worktree: PathBuf::from("/wt/A"),
7650            summary: summary.to_owned(),
7651            stat: String::new(),
7652            files: 1,
7653            commits: 1,
7654            empty: false,
7655            failed: None,
7656            verified_noop: None,
7657            duration_ms: 0,
7658            folded: false,
7659        }
7660    }
7661
7662    fn uncontested_tally() -> Tally {
7663        Tally {
7664            first_choice: BTreeMap::from([('A', 1)]),
7665            borda: BTreeMap::new(),
7666            winner: 'A',
7667            rankings: 1,
7668            unanimous_initial: true,
7669            deliberated: false,
7670            changed_votes: 0,
7671            unanimous_final: true,
7672            tie_break: None,
7673            judges: 1,
7674            present: 1,
7675            quorum: 1,
7676            met_quorum: true,
7677            uncontested: None,
7678        }
7679    }
7680
7681    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
7682        ReviewRecord {
7683            attempts: 0,
7684            reviewer,
7685            agent: agent.to_owned(),
7686            summary: summary.to_owned(),
7687            findings: Vec::new(),
7688            vote: None,
7689            failed: None,
7690            duration_ms: 0,
7691        }
7692    }
7693
7694    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
7695        let answered = reviews.len();
7696        ReviewRound {
7697            round,
7698            head: "abc1234".to_owned(),
7699            verified_head: None,
7700            verified_at: None,
7701            reviews,
7702            e2e: Vec::new(),
7703            verify_retried: false,
7704            e2e_deferred: false,
7705            e2e_defer_reason: None,
7706            fix: None,
7707            blocking: 0,
7708            answered,
7709            expected: answered,
7710            clean: true,
7711            progressed: false,
7712            vote_split: false,
7713            reconsideration: Vec::new(),
7714            verdict: None,
7715        }
7716    }
7717
7718    #[test]
7719    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
7720        let en = panel();
7721        assert!(en.contains("Task"), "{en}");
7722        assert!(en.contains("add retries to the uploader"), "{en}");
7723
7724        let mut state = run_state();
7725        state.config.graph.language = "ja".to_owned();
7726        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7727        assert!(ja.contains("タスク"), "{ja}");
7728        assert!(
7729            ja.contains("add retries to the uploader"),
7730            "the task itself is not translated: {ja}"
7731        );
7732    }
7733
7734    #[test]
7735    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
7736        // `run_state()` has no candidates, no tally and no reviews - exactly
7737        // the shape a run has before anything has judged or reviewed it, and
7738        // the panel must not print an empty box for either.
7739        let html = panel();
7740        assert!(!html.contains("What changed"), "{html}");
7741        assert!(!html.contains("Review verdict"), "{html}");
7742    }
7743
7744    #[test]
7745    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
7746        let mut state = run_state();
7747        state.candidates = vec![winning_candidate("")];
7748        state.tally = Some(uncontested_tally());
7749        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7750        assert!(
7751            !html.contains("What changed"),
7752            "an empty summary must not render an empty box: {html}"
7753        );
7754    }
7755
7756    #[test]
7757    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
7758        let mut state = run_state();
7759        state.candidates = vec![winning_candidate(
7760            "Added a retry loop around the uploader PUT call.",
7761        )];
7762        state.tally = Some(uncontested_tally());
7763        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7764        assert!(en.contains("What changed"), "{en}");
7765        assert!(
7766            en.contains("Added a retry loop around the uploader PUT call."),
7767            "{en}"
7768        );
7769
7770        state.config.graph.language = "ja".to_owned();
7771        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7772        assert!(ja.contains("変更内容"), "{ja}");
7773        assert!(
7774            ja.contains("Added a retry loop around the uploader PUT call."),
7775            "{ja}"
7776        );
7777    }
7778
7779    #[test]
7780    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
7781        let mut state = run_state();
7782        state.reviews = vec![
7783            review_round(
7784                1,
7785                vec![review_record(1, "alpha", "found a race, sent back")],
7786            ),
7787            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
7788        ];
7789        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7790        assert!(en.contains("Review verdict"), "{en}");
7791        assert!(en.contains("race is fixed, clean"), "{en}");
7792        assert!(
7793            !en.contains("found a race, sent back"),
7794            "only the round that actually cleared the merge should show: {en}"
7795        );
7796
7797        state.config.graph.language = "ja".to_owned();
7798        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7799        assert!(ja.contains("レビューの結論"), "{ja}");
7800        assert!(ja.contains("レビュアー"), "{ja}");
7801        assert!(ja.contains("race is fixed, clean"), "{ja}");
7802    }
7803
7804    /// The `incomplete_review = "warn"` policy (see
7805    /// `graph::Runner::review_loop`) can push a `clean` round to
7806    /// `state.reviews` while one seat's own record still has `failed: Some`
7807    /// and an empty `summary` - a seat that never answered, not one that
7808    /// answered with nothing to say.
7809    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
7810        ReviewRecord {
7811            attempts: 0,
7812            reviewer,
7813            agent: agent.to_owned(),
7814            summary: String::new(),
7815            findings: Vec::new(),
7816            vote: None,
7817            failed: Some(reason.to_owned()),
7818            duration_ms: 0,
7819        }
7820    }
7821
7822    #[test]
7823    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
7824        let mut state = run_state();
7825        state.reviews = vec![review_round(
7826            1,
7827            vec![
7828                review_record(1, "alpha", "clean, nothing to add"),
7829                unanswered_review_record(2, "beta", "timed out"),
7830            ],
7831        )];
7832        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7833        assert!(en.contains("clean, nothing to add"), "{en}");
7834        assert!(
7835            en.contains("produced no answer: timed out"),
7836            "a seat that never answered must say so, not render a blank box: {en}"
7837        );
7838        assert!(
7839            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
7840            "no reviewer box may be left empty: {en}"
7841        );
7842
7843        state.config.graph.language = "ja".to_owned();
7844        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
7845        assert!(ja.contains("回答なし: timed out"), "{ja}");
7846    }
7847
7848    #[test]
7849    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
7850        let html = panel();
7851        assert!(!html.contains("<script"), "no script survives the csp");
7852        assert!(!html.contains("<form"), "form-action is 'none'");
7853        let pr = green_pr();
7854        assert_eq!(
7855            html.matches("http").count(),
7856            html.matches(pr.url.as_str()).count(),
7857            "the only http url in the panel is the pull request's own link"
7858        );
7859        assert!(
7860            html.contains("target=\"_blank\""),
7861            "the PR link must open in a new tab so it escapes the iframe"
7862        );
7863        assert!(
7864            html.contains("rel=\"noopener noreferrer\""),
7865            "external links opening in new tabs must carry noopener noreferrer"
7866        );
7867    }
7868
7869    #[test]
7870    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
7871        let html = panel();
7872        assert!(
7873            html.contains(">+</span>"),
7874            "an added line carries a `+` in the gutter, not only a background"
7875        );
7876        assert!(
7877            html.contains(">-</span>"),
7878            "a removed line carries a `-` in the gutter, not only a background"
7879        );
7880        assert!(
7881            html.contains(">new line</span>"),
7882            "the marker is moved to the gutter, so the body is printed once without it"
7883        );
7884    }
7885
7886    #[test]
7887    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
7888        let total = DIFF_MAX_LINES + 100;
7889        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
7890        let html = approval_panel(
7891            &run_state(),
7892            &green_pr(),
7893            NUMSTAT,
7894            &diff,
7895            &[],
7896            "feat: something long",
7897        );
7898        assert!(
7899            html.contains(&format!("100 of {total} diff lines omitted")),
7900            "the note must say exactly how much was cut"
7901        );
7902        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
7903        assert!(
7904            !html.contains(&format!("line {DIFF_MAX_LINES}")),
7905            "nothing past the threshold is rendered"
7906        );
7907        assert!(
7908            html.contains("/repo/magi"),
7909            "the note says where the rest is"
7910        );
7911    }
7912
7913    #[test]
7914    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
7915        let html = approval_panel(
7916            &run_state(),
7917            &green_pr(),
7918            "1\t2\tsrc/<b>&\"x\"'.rs",
7919            "",
7920            &[],
7921            "subject",
7922        );
7923        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
7924        assert!(
7925            !html.contains("<b>"),
7926            "an agent-influenced path must never become markup"
7927        );
7928    }
7929
7930    #[tokio::test]
7931    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
7932        let a = std::path::PathBuf::from("/repo/a");
7933        let b = std::path::PathBuf::from("/repo/b");
7934
7935        let held = repo_merge_lock(&a).lock_owned().await;
7936
7937        // A second, concurrent land run against the *same* repository must
7938        // wait - `try_lock` fails while `held` is alive.
7939        assert!(
7940            repo_merge_lock(&a).try_lock().is_err(),
7941            "a second merge into the same repository must not proceed concurrently"
7942        );
7943
7944        // A run against a *different* repository must not be blocked by it -
7945        // this is what keeps a slow rebase or `gh pr merge` in one
7946        // repository from also stalling a land-approval resume in another.
7947        assert!(
7948            repo_merge_lock(&b).try_lock().is_ok(),
7949            "a different repository's merge lock must be independent"
7950        );
7951
7952        drop(held);
7953        assert!(
7954            repo_merge_lock(&a).try_lock().is_ok(),
7955            "the lock is released once the holder is done"
7956        );
7957    }
7958
7959    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
7960        let mut state = landing_state();
7961        let mut rec = review_record(1, "alpha", "precise rejection reason");
7962        rec.vote = Some(if reject {
7963            crate::verdict::ReviewVote::Reject
7964        } else {
7965            crate::verdict::ReviewVote::Approve
7966        });
7967        if major {
7968            rec.findings = contested().findings;
7969        }
7970        let mut round = review_round(1, vec![rec]);
7971        round.clean = false;
7972        round.blocking = usize::from(major);
7973        state.reviews.push(round);
7974        state.config.graph.review_rounds = 1;
7975        state
7976    }
7977
7978    #[test]
7979    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
7980        for every in [false, true] {
7981            for contested in [false, true] {
7982                for major in [false, true] {
7983                    for reject in [false, true] {
7984                        let mut state = repair_state(major, reject);
7985                        state.config.graph.land_approval = every;
7986                        state.config.graph.hold_contested_merge = contested;
7987                        let reasons = approval_reasons(&state);
7988                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
7989                        assert_eq!(
7990                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
7991                            contested && major
7992                        );
7993                        assert_eq!(
7994                            reasons.contains(&ApprovalReason::FinalReject),
7995                            contested && reject
7996                        );
7997                        assert_eq!(
7998                            reasons.len(),
7999                            usize::from(every)
8000                                + usize::from(contested && major)
8001                                + usize::from(contested && reject)
8002                        );
8003                    }
8004                }
8005            }
8006        }
8007    }
8008
8009    #[test]
8010    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8011        use crate::verdict::ReviewVote::{Approve, Reject};
8012        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8013            let mut state = repair_state(false, initial_reject);
8014            state.reviews[0]
8015                .reconsideration
8016                .push(crate::run::ReviewRevoteRecord {
8017                    reviewer: 1,
8018                    agent: "alpha".into(),
8019                    vote: Some(final_vote),
8020                    reason: "changed after reading the panel <evidence>".into(),
8021                    failed: None,
8022                });
8023            assert_eq!(
8024                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8025                final_vote == Reject
8026            );
8027            for language in ["en", "ja"] {
8028                state.config.graph.language = language.into();
8029                let evidence = approval_evidence(&state);
8030                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8031                assert!(evidence.contains("changed after reading the panel <evidence>"));
8032                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8033                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8034                assert!(!html.contains("<evidence>"));
8035            }
8036            state.reviews[0].reconsideration[0].vote = None;
8037            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8038            assert_eq!(
8039                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8040                initial_reject
8041            );
8042            assert!(approval_evidence(&state).contains("timed out"));
8043        }
8044    }
8045
8046    #[tokio::test]
8047    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8048        for every in [false, true] {
8049            for contested in [false, true] {
8050                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8051                {
8052                    let mut state = repair_state(major, reject);
8053                    state.config.graph.land_approval = every;
8054                    state.config.graph.hold_contested_merge = contested;
8055                    let forge = Scripted::new(
8056                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8057                        vec![(true, "")],
8058                    );
8059                    land_with(&mut state, URL, &forge).await.unwrap();
8060                    let expected = every || (contested && (major || reject));
8061                    assert_eq!(state.land_approval.is_some(), expected);
8062                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8063                    if expected {
8064                        assert!(state.parked);
8065                    }
8066                }
8067            }
8068        }
8069    }
8070
8071    #[tokio::test]
8072    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8073        let mut state = repair_state(false, true);
8074        state.reviews[0].clean = true;
8075        let mut finding = contested().findings.remove(0);
8076        finding.severity = crate::verdict::Severity::Minor;
8077        state.reviews[0].reviews[0].findings.push(finding);
8078        assert!(state.open_findings().is_empty());
8079        let mut pr = green_pr();
8080        pr.review_comments.clear();
8081        for language in ["en", "ja"] {
8082            state.config.graph.language = language.into();
8083            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8084            assert!(html.contains("[R3-1-1] minor"));
8085            assert!(html.contains(if language == "ja" {
8086                "内部指摘の解決を意味するものではありません"
8087            } else {
8088                "This does not mean internal findings are resolved"
8089            }));
8090            assert!(
8091                html.find(if language == "ja" {
8092                    "承認が必要な理由:"
8093                } else {
8094                    "Approval required because:"
8095                })
8096                .unwrap()
8097                    < html.find("[R3-1-1]").unwrap()
8098            );
8099        }
8100        state.config.graph.language = "en".into();
8101        let observed = seen("abc", Checks::Green, "CLEAN", false);
8102        assert_eq!(
8103            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8104                .await
8105                .unwrap(),
8106            ApprovalGate::Pending
8107        );
8108        let id = state.land_approval.as_ref().unwrap().question.clone();
8109        let q = ask::Questions::open().get(&id).unwrap();
8110        assert!(q.detail.starts_with("Approval required because:"));
8111        assert!(q.detail.contains("final reviewer reject"));
8112        assert!(q.detail.contains("[R3-1-1] minor"));
8113        assert!(
8114            q.detail
8115                .contains("No adoption or resolution evidence recorded")
8116        );
8117        state.config.graph.land_approval = false;
8118        state.config.graph.hold_contested_merge = false;
8119        state.reviews.clear();
8120        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8121        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8122        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8123        let forge = Scripted::new(vec![observed], vec![]);
8124        land_with(&mut state, URL, &forge).await.unwrap();
8125        assert!(!forge.calls().contains(&"merge"));
8126        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8127        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8128    }
8129
8130    #[tokio::test]
8131    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8132        let mut state = repair_state(false, true);
8133        state.config.graph.land_approval = false;
8134        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8135        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8136        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8137        state.reviews[0].reviews[0].summary = long_reason.clone();
8138        for i in 1..=10 {
8139            let mut finding = contested().findings.remove(0);
8140            finding.id = format!("R1-1-{i}");
8141            finding.severity = if i == 9 {
8142                crate::verdict::Severity::Major
8143            } else {
8144                crate::verdict::Severity::Minor
8145            };
8146            finding.title = long_title.clone();
8147            finding.detail = long_detail.clone();
8148            state.reviews[0].reviews[0].findings.push(finding);
8149        }
8150        state.reviews[0].fix = Some(crate::run::FixRecord {
8151            agent: "fixer".into(),
8152            addressed: vec!["R1-1-9".into()],
8153            rejected: vec![crate::verdict::Rejection {
8154                id: "R1-1-10".into(),
8155                why: long_reason.clone(),
8156            }],
8157            notes: long_detail.clone(),
8158            committed: false,
8159            failed: None,
8160            duration_ms: 0,
8161            continuation: None,
8162        });
8163        assert_eq!(
8164            approval_reasons(&state),
8165            [
8166                ApprovalReason::OpenBlockingFindings,
8167                ApprovalReason::FinalReject
8168            ]
8169        );
8170        let excerpt = approval_excerpt(&long_reason, 300);
8171        assert_eq!(excerpt.chars().count(), 300);
8172        assert!(excerpt.ends_with('…'));
8173        let boundary = "拒".repeat(300);
8174        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8175        for language in ["en", "ja"] {
8176            state.config.graph.language = language.into();
8177            for revote in [
8178                None,
8179                Some(crate::verdict::ReviewVote::Reject),
8180                Some(crate::verdict::ReviewVote::Approve),
8181            ] {
8182                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8183                state.reviews[0].reconsideration.clear();
8184                if let Some(vote) = revote {
8185                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8186                    state.reviews[0]
8187                        .reconsideration
8188                        .push(crate::run::ReviewRevoteRecord {
8189                            reviewer: 1,
8190                            agent: "alpha".into(),
8191                            vote: Some(vote),
8192                            reason: long_reason.clone(),
8193                            failed: None,
8194                        });
8195                }
8196                let evidence = approval_evidence(&state);
8197                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8198                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8199                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8200                assert_eq!(
8201                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8202                    final_vote == crate::verdict::ReviewVote::Reject
8203                );
8204                assert!(evidence.contains(&excerpt));
8205                assert!(evidence.contains("[R1-1-8] minor"));
8206                assert!(evidence.contains("[R1-1-9] major"));
8207                assert!(evidence.contains("[R1-1-10] minor"));
8208                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8209                    assert!(!evidence.contains(tail));
8210                }
8211                assert!(evidence.contains(if language == "ja" {
8212                    "採用申告(解決未確認)"
8213                } else {
8214                    "claimed addressed (resolution unconfirmed)"
8215                }));
8216                assert!(evidence.contains(if language == "ja" {
8217                    "不採用理由"
8218                } else {
8219                    "declined"
8220                }));
8221                let major_evidence = evidence
8222                    .split("[R1-1-9] major")
8223                    .nth(1)
8224                    .unwrap()
8225                    .split("[R1-1-10]")
8226                    .next()
8227                    .unwrap();
8228                assert!(major_evidence.contains(if language == "ja" {
8229                    "採用申告(解決未確認)"
8230                } else {
8231                    "claimed addressed (resolution unconfirmed)"
8232                }));
8233                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8234                assert!(last_evidence.contains(if language == "ja" {
8235                    "不採用理由"
8236                } else {
8237                    "declined"
8238                }));
8239                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8240                assert!(html.contains(&esc(&evidence)));
8241                assert_eq!(
8242                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8243                        .await
8244                        .unwrap(),
8245                    ApprovalGate::Pending
8246                );
8247                let id = &state.land_approval.take().unwrap().question;
8248                let q = ask::Questions::open().get(id).unwrap();
8249                assert!(q.detail.starts_with(&evidence));
8250            }
8251            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8252        }
8253    }
8254
8255    #[test]
8256    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8257        for (major, reject) in [(true, false), (false, true), (true, true)] {
8258            let state = repair_state(major, reject);
8259            assert!(repair_available(&state));
8260            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8261        }
8262        assert!(!repair_available(&repair_state(false, false)));
8263        let mut clean = repair_state(true, false);
8264        clean.reviews[0].clean = true;
8265        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8266        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8267    }
8268
8269    #[tokio::test]
8270    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8271        let mut state = repair_state(true, false);
8272        let pr = green_pr();
8273        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8274            .await
8275            .unwrap();
8276        let store = ask::Questions::open();
8277        let id = state.land_approval.as_ref().unwrap().question.clone();
8278        // Simulate an unanswered two-choice question created by an older build.
8279        store
8280            .update(&id, |q| {
8281                q.choices = vec![APPROVE.into(), HOLD.into()];
8282                Ok(())
8283            })
8284            .unwrap();
8285        let upgraded = store.get(&id).unwrap();
8286        assert_eq!(upgraded.choices, approval_choices(&state));
8287        assert!(!upgraded.actions.contains_key(HOLD));
8288        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8289        assert!(
8290            store
8291                .list()
8292                .iter()
8293                .any(|q| q.id == id && q.choices == upgraded.choices)
8294        );
8295        assert!(
8296            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8297        );
8298        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8299        store
8300            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8301            .unwrap();
8302        // An earlier build may have recorded the repair answer and stopped
8303        // before any driver consumed it. Reconcile that durable decision too.
8304        store
8305            .update(&id, |q| {
8306                q.actions.clear();
8307                Ok(())
8308            })
8309            .unwrap();
8310        assert_eq!(
8311            store.get(&id).unwrap().chosen_action(),
8312            Some(&ask::ChoiceAction::Resume {
8313                run: state.id.clone()
8314            })
8315        );
8316        let binding = state.land_approval.clone();
8317        assert_eq!(
8318            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8319                .await
8320                .unwrap(),
8321            ApprovalGate::Repair
8322        );
8323        assert!(state.land_approval.is_none());
8324        assert_eq!(state.merge_repairs.len(), 1);
8325        let request = &state.merge_repairs[0];
8326        assert_eq!(request.question, id);
8327        assert_eq!(request.head, "abc");
8328        assert_eq!(request.pr, pr.url);
8329        assert_eq!(
8330            request.reviews[0].reviews[0].summary,
8331            "precise rejection reason"
8332        );
8333        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8334        assert_eq!(
8335            request.reviews[0].reviews[0].findings[0].detail,
8336            state.reviews[0].reviews[0].findings[0].detail
8337        );
8338        // A repeated consumption of the same answer never dispatches another repair.
8339        state.land_approval = binding;
8340        assert_eq!(
8341            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8342                .await
8343                .unwrap(),
8344            ApprovalGate::Repair
8345        );
8346        assert_eq!(state.merge_repairs.len(), 1);
8347        state.land_approval = None;
8348        state.config.graph.land_approval = false;
8349        state.config.graph.hold_contested_merge = false;
8350        state.reviews.clear();
8351        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8352        assert_eq!(
8353            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8354                .await
8355                .unwrap(),
8356            ApprovalGate::Pending
8357        );
8358        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8359        assert_ne!(fresh_id, &id);
8360        let fresh = store.get(fresh_id).unwrap();
8361        assert!(
8362            fresh
8363                .detail
8364                .contains("fresh owner approval after merge repair")
8365        );
8366        assert!(fresh.detail.contains("No review recorded"));
8367        assert!(!fresh.detail.contains("precise rejection reason"));
8368    }
8369
8370    #[tokio::test]
8371    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8372        for choice in [REPAIR, HOLD] {
8373            let mut state = repair_state(false, true);
8374            let observed = seen("abc", Checks::Green, "CLEAN", false);
8375            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8376                .await
8377                .unwrap();
8378            let id = state.land_approval.as_ref().unwrap().question.clone();
8379            ask::Questions::open()
8380                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8381                .unwrap();
8382            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8383            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8384                .await
8385                .unwrap();
8386            assert!(
8387                forge
8388                    .argvs()
8389                    .iter()
8390                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8391            );
8392            assert!(!forge.calls().contains(&"fix"));
8393            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8394            assert_ne!(state.status, RunStatus::Merged);
8395        }
8396    }
8397
8398    #[test]
8399    fn only_the_merge_choice_merges_and_silence_holds() {
8400        let table = [
8401            (None, Approval::Hold),
8402            (Some("merge"), Approval::Merge),
8403            (Some(" merge\n"), Approval::Merge),
8404            (Some("hold"), Approval::Hold),
8405            (Some(""), Approval::Hold),
8406            (Some("yes"), Approval::Hold),
8407        ];
8408        for (answer, want) in table {
8409            assert_eq!(
8410                approval(answer),
8411                want,
8412                "answer {answer:?} must resolve to {want:?}"
8413            );
8414        }
8415    }
8416
8417    #[tokio::test]
8418    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8419        let mut state = landing_state();
8420        state.config.graph.land_approval = true;
8421        let pr = green_pr();
8422
8423        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8424            .await
8425            .unwrap();
8426        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8427        assert!(
8428            !state.parked,
8429            "approval_gate itself never sets `parked`; only its caller does"
8430        );
8431
8432        let store = ask::Questions::open();
8433        let filed: Vec<_> = store
8434            .list()
8435            .into_iter()
8436            .filter(|q| q.run == state.id)
8437            .collect();
8438        assert_eq!(filed.len(), 1, "exactly one question is filed");
8439        assert_eq!(filed[0].node, APPROVAL_NODE);
8440        assert_eq!(filed[0].choices, approval_choices(&state));
8441        assert!(filed[0].status.open());
8442
8443        // A second visit - standing in for a resumed run whose slot the
8444        // daemon handed to something else while nobody had answered - must
8445        // find the same question rather than filing a second one.
8446        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8447            .await
8448            .unwrap();
8449        assert_eq!(again, ApprovalGate::Pending);
8450        let still_one = store
8451            .list()
8452            .into_iter()
8453            .filter(|q| q.run == state.id)
8454            .count();
8455        assert_eq!(
8456            still_one, 1,
8457            "asking twice must not double-file the question"
8458        );
8459    }
8460
8461    #[tokio::test]
8462    async fn approving_the_existing_question_is_read_back_as_approved() {
8463        crate::run::pin_test_home();
8464        let mut state = run_state();
8465        state.config.graph.land_approval = true;
8466        let pr = green_pr();
8467        assert_eq!(
8468            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8469                .await
8470                .unwrap(),
8471            ApprovalGate::Pending
8472        );
8473
8474        let store = ask::Questions::open();
8475        let mut q = store
8476            .list()
8477            .into_iter()
8478            .find(|q| q.run == state.id)
8479            .expect("filed above");
8480        let talks = crate::talk::Talks::open();
8481        let cfg = crate::config::Config {
8482            agents: vec![crate::config::AgentSpec {
8483                id: "mock".into(),
8484                kind: crate::config::AgentKind::Command,
8485                model: None,
8486                command: vec!["true".into()],
8487                extra_args: Vec::new(),
8488                env: Default::default(),
8489                prompt_delivery: None,
8490            }],
8491            ..crate::config::Config::default()
8492        };
8493        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8494        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8495        crate::talk::drain(&mut talk, &talks).unwrap();
8496        assert_eq!(
8497            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8498                .await
8499                .unwrap(),
8500            ApprovalGate::Pending,
8501            "consultation cannot approve or hold the merge"
8502        );
8503        q = store.get(&q.id).unwrap();
8504        assert!(q.answer.is_none());
8505        talk.turns.push(crate::talk::Turn {
8506            sender: None,
8507            breaks: Some(Vec::new()),
8508            messages: Vec::new(),
8509            posted_by: None,
8510            who: crate::talk::Who::Operator,
8511            body: "merge abc".into(),
8512            at: Timestamp::now(),
8513            attachments: Vec::new(),
8514            usage: None,
8515        });
8516        talks.put(&mut talk).unwrap();
8517        crate::consult::validate_answer(
8518            &q,
8519            &talks,
8520            &talk.id,
8521            crate::queue::CHAT_NODE,
8522            APPROVE,
8523            Some("merge abc"),
8524        )
8525        .unwrap();
8526        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8527        store.put(&mut q).unwrap();
8528
8529        assert_eq!(
8530            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8531                .await
8532                .unwrap(),
8533            ApprovalGate::Approved
8534        );
8535    }
8536
8537    #[tokio::test]
8538    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8539        crate::run::pin_test_home();
8540        let store = ask::Questions::open();
8541        for via_list in [false, true] {
8542            let mut state = repair_state(false, true);
8543            let pr = green_pr();
8544            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8545                .await
8546                .unwrap();
8547            let id = state.land_approval.as_ref().unwrap().question.clone();
8548            store
8549                .update(&id, |q| {
8550                    q.actions.insert(
8551                        HOLD.to_owned(),
8552                        ask::ChoiceAction::Resume {
8553                            run: state.id.clone(),
8554                        },
8555                    );
8556                    Ok(())
8557                })
8558                .unwrap();
8559            let q = if via_list {
8560                store.list().into_iter().find(|q| q.id == id).unwrap()
8561            } else {
8562                store.get(&id).unwrap()
8563            };
8564            assert!(!q.actions.contains_key(HOLD));
8565            store
8566                .update(&id, |q| {
8567                    // Check the persisted migration, without another read upgrade.
8568                    assert!(!q.actions.contains_key(HOLD));
8569                    assert!(!upgrade_repair_choice(q, &state));
8570                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
8571                })
8572                .unwrap();
8573            assert!(store.get(&id).unwrap().chosen_action().is_none());
8574            assert_eq!(
8575                approval_gate(&mut state, &pr, "feat: x", None, "abc")
8576                    .await
8577                    .unwrap(),
8578                ApprovalGate::Held
8579            );
8580        }
8581    }
8582
8583    #[tokio::test]
8584    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
8585        crate::run::pin_test_home();
8586        let store = ask::Questions::open();
8587
8588        let mut held_state = run_state();
8589        held_state.config.graph.land_approval = true;
8590        let pr = green_pr();
8591        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8592            .await
8593            .unwrap();
8594        let mut q = store
8595            .list()
8596            .into_iter()
8597            .find(|q| q.run == held_state.id)
8598            .expect("filed above");
8599        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
8600        store.put(&mut q).unwrap();
8601        assert_eq!(
8602            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
8603                .await
8604                .unwrap(),
8605            ApprovalGate::Held
8606        );
8607
8608        let mut abandoned_state = run_state();
8609        abandoned_state.config.graph.land_approval = true;
8610        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8611            .await
8612            .unwrap();
8613        let mut q = store
8614            .list()
8615            .into_iter()
8616            .find(|q| q.run == abandoned_state.id)
8617            .expect("filed above");
8618        q.abandon("no answer within the timeout");
8619        store.put(&mut q).unwrap();
8620        assert_eq!(
8621            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
8622                .await
8623                .unwrap(),
8624            ApprovalGate::Pending,
8625            "an expired approval must ask again without merging"
8626        );
8627        assert_eq!(
8628            store.get(&q.id).unwrap().status,
8629            ask::QuestionStatus::Abandoned
8630        );
8631        assert_ne!(
8632            abandoned_state.land_approval.as_ref().unwrap().question,
8633            q.id
8634        );
8635    }
8636
8637    fn contested() -> ContestedHandoff {
8638        let finding = |id: &str, n: u32| crate::verdict::Finding {
8639            id: id.to_owned(),
8640            severity: crate::verdict::Severity::Major,
8641            file: Some("src/a.rs".to_owned()),
8642            line: Some(n),
8643            title: format!("problem {id}"),
8644            detail: String::new(),
8645        };
8646        ContestedHandoff {
8647            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
8648            rejecters: vec![(1, "alpha".to_owned())],
8649        }
8650    }
8651
8652    #[test]
8653    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
8654        let mut state = run_state();
8655        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
8656        state.contested_handoff = Some(contested());
8657        assert!(contested_to_ask(&state).is_some());
8658        state.config.graph.hold_contested_merge = false;
8659        assert!(
8660            contested_to_ask(&state).is_none(),
8661            "the switch restores today"
8662        );
8663    }
8664
8665    #[test]
8666    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
8667        let q = ask::Question::new(
8668            "run-1".to_owned(),
8669            APPROVAL_NODE.to_owned(),
8670            "land".to_owned(),
8671            "Merge?".to_owned(),
8672            String::new(),
8673            vec![APPROVE.to_owned(), HOLD.to_owned()],
8674        );
8675        let none = deputy_brief(&q, None);
8676        assert!(none.contains("could not be read"), "{none}");
8677        assert!(none.contains("Silence is a hold"), "{none}");
8678
8679        let mut state = run_state();
8680        state.pr = Some(crate::run::PrRecord {
8681            url: "https://example.test/pull/7".to_owned(),
8682            number: 7,
8683            state: "open".to_owned(),
8684            checks: "green".to_owned(),
8685            round: 0,
8686            rounds: 3,
8687            red_at_merge: Vec::new(),
8688        });
8689        state.contested_handoff = Some(contested());
8690        let b = deputy_brief(&q, Some(&state));
8691        assert!(b.contains("https://example.test/pull/7"), "{b}");
8692        assert!(b.contains("not recorded in this legacy question"), "{b}");
8693        assert!(!b.contains("This question was filed although"));
8694        state.contested_handoff = None;
8695        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
8696    }
8697
8698    #[test]
8699    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
8700        for lang in ["en", "ja"] {
8701            let mut cfg = crate::config::Config::default();
8702            cfg.graph.language = lang.to_owned();
8703            let w = words(&cfg.graph.language);
8704            let text = w.approval_detail(
8705                "https://github.com/yukimemi/magi/pull/42",
8706                "main",
8707                "feat: x",
8708                Some(&contested()),
8709            );
8710            assert!(text.contains("pull/42"), "{text}");
8711            assert!(
8712                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
8713                "{text}"
8714            );
8715            assert!(text.contains("R3-1-5"), "{text}");
8716            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
8717            assert!(text.contains("2"), "the rest are counted: {text}");
8718            assert!(text.contains("#1 (alpha)"), "{text}");
8719        }
8720        let plain = words("en").approval_detail("u", "main", "s", None);
8721        assert!(!plain.contains("reject"), "{plain}");
8722    }
8723
8724    #[tokio::test]
8725    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
8726        crate::run::pin_test_home();
8727        let mut state = run_state();
8728        state.config.graph.land_approval = false;
8729        state.contested_handoff = Some(contested());
8730        let pr = green_pr();
8731        let c = contested_to_ask(&state);
8732        assert_eq!(
8733            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8734                .await
8735                .unwrap(),
8736            ApprovalGate::Pending,
8737            "silence is a hold"
8738        );
8739        let store = ask::Questions::open();
8740        let filed: Vec<_> = store
8741            .list()
8742            .into_iter()
8743            .filter(|q| q.run == state.id)
8744            .collect();
8745        assert_eq!(filed.len(), 1);
8746        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
8747
8748        assert_eq!(
8749            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8750                .await
8751                .unwrap(),
8752            ApprovalGate::Pending
8753        );
8754        let mut q = store
8755            .list()
8756            .into_iter()
8757            .find(|q| q.run == state.id)
8758            .unwrap();
8759        assert_eq!(q.id, filed[0].id, "the same question after a resume");
8760        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8761        store.put(&mut q).unwrap();
8762        assert_eq!(
8763            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
8764                .await
8765                .unwrap(),
8766            ApprovalGate::Approved
8767        );
8768    }
8769
8770    #[test]
8771    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
8772        let rows = parse_numstat(NUMSTAT);
8773        assert_eq!(
8774            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
8775            ["src/web.rs", "src/land.rs", "assets/logo.png"]
8776        );
8777        assert_eq!(rows[2].added, None, "a binary file has no line counts");
8778    }
8779    #[test]
8780    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
8781        // Reported from a real run: the merge question arrived in English on a
8782        // repository with `language = "ja"`. magi's own strings have to follow
8783        // that setting too - "it is a literal in Rust" is not an answer.
8784        let mut state = run_state();
8785        state.config.graph.language = "ja".to_owned();
8786        let pr = green_pr();
8787        let commits = ["c1".to_owned()];
8788
8789        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8790        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
8791        assert!(ja.contains("squash されるコミット"), "{ja}");
8792        assert!(ja.contains("レビューコメント"), "{ja}");
8793        assert!(ja.contains("差分"), "{ja}");
8794        assert!(
8795            !ja.contains("Commits being squashed"),
8796            "no English left over"
8797        );
8798
8799        let w = words("ja");
8800        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
8801        assert!(
8802            w.approval_detail("http://x/1", "main", "feat: x", None)
8803                .contains("パネル")
8804        );
8805
8806        // The evidence itself is language-neutral and must survive either way.
8807        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
8808        assert!(ja.contains("feat: x"), "nor is the merge subject");
8809
8810        // English stays the default, and a language magi cannot check falls
8811        // back to it rather than shipping a guess.
8812        state.config.graph.language = "en".to_owned();
8813        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
8814        assert!(en.contains("Commits being squashed"), "{en}");
8815        assert_eq!(words("Klingon").html_lang, "en");
8816    }
8817
8818    /// A `gh pr list` result naming exactly one pull request whose base and
8819    /// merge time both fit the run is exactly the case
8820    /// [`find_external_merge`] exists to act on.
8821    #[test]
8822    fn pick_open_pr_classifies_by_count_and_base() {
8823        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
8824        assert_eq!(
8825            pick_open_pr(one, "main").unwrap(),
8826            OpenPr::One {
8827                url: "https://x/pull/58".into(),
8828                title: "t".into()
8829            }
8830        );
8831        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
8832        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
8833        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
8834                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
8835        assert_eq!(
8836            pick_open_pr(two, "main").unwrap(),
8837            OpenPr::Many(vec!["u1".into(), "u2".into()])
8838        );
8839        // A fork's same-named branch is not a pull request for this head.
8840        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
8841        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
8842        assert!(pick_open_pr("not json", "main").is_err());
8843        // An incomplete record is an error, never "nothing open".
8844        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
8845        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
8846    }
8847
8848    #[test]
8849    fn pick_merged_pr_picks_the_unique_match() {
8850        let json = r#"[
8851            {"url": "https://github.com/o/r/pull/42", "number": 42,
8852             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
8853        ]"#;
8854        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8855        let found = pick_merged_pr(json, "main", created_at)
8856            .expect("valid json")
8857            .expect("one unambiguous match");
8858        assert_eq!(found.url, "https://github.com/o/r/pull/42");
8859        assert_eq!(found.number, 42);
8860    }
8861
8862    /// Two candidates surviving the filter is exactly as uninformative as
8863    /// zero — a branch name can be reused across runs — so neither is
8864    /// preferred over the other and nothing is recorded automatically.
8865    #[test]
8866    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
8867        let json = r#"[
8868            {"url": "https://github.com/o/r/pull/42", "number": 42,
8869             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
8870            {"url": "https://github.com/o/r/pull/43", "number": 43,
8871             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
8872        ]"#;
8873        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8874        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
8875    }
8876
8877    /// A pull request that targets a different base branch cannot be this
8878    /// run's, whatever its head branch is named — a reused branch name from
8879    /// an unrelated task must not be recorded as this run's merge.
8880    #[test]
8881    fn pick_merged_pr_ignores_a_different_base_branch() {
8882        let json = r#"[
8883            {"url": "https://github.com/o/r/pull/42", "number": 42,
8884             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
8885        ]"#;
8886        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8887        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
8888    }
8889
8890    /// A pull request merged before this run was even created cannot be this
8891    /// run's winner, no matter how its head branch is spelled.
8892    #[test]
8893    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
8894        let json = r#"[
8895            {"url": "https://github.com/o/r/pull/42", "number": 42,
8896             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
8897        ]"#;
8898        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
8899        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
8900    }
8901
8902    #[test]
8903    fn slug_of_pr_url_reads_host_owner_and_repo() {
8904        assert_eq!(
8905            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
8906            Some("github.com/yukimemi/shun")
8907        );
8908    }
8909
8910    #[test]
8911    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
8912        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
8913        assert_eq!(slug_of_pr_url("not a url at all"), None);
8914        assert_eq!(slug_of_pr_url("https://github.com"), None);
8915    }
8916
8917    #[test]
8918    fn slug_of_repo_url_reads_host_owner_and_repo() {
8919        assert_eq!(
8920            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
8921            Some("github.com/yukimemi/magi")
8922        );
8923        assert_eq!(slug_of_repo_url("https://github.com"), None);
8924    }
8925
8926    #[test]
8927    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
8928        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
8929            .expect("same repo, different case");
8930    }
8931
8932    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
8933    /// own in-progress run and rewrote its status from a pull request in a
8934    /// completely different repository. This is the guard that must catch
8935    /// that even when an explicit (but wrong) id is given.
8936    #[test]
8937    fn ensure_same_repo_refuses_a_different_repo() {
8938        let err =
8939            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
8940        let msg = format!("{err:#}");
8941        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
8942        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
8943    }
8944
8945    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
8946    /// instance mirroring a `github.com` repository's name, say) must not be
8947    /// treated as the same repository just because the trailing path
8948    /// matches.
8949    #[test]
8950    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
8951        let err = ensure_same_repo(
8952            "github.com/yukimemi/magi",
8953            "github.example.com/yukimemi/magi",
8954        )
8955        .unwrap_err();
8956        let msg = format!("{err:#}");
8957        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
8958        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
8959    }
8960
8961    /// No winner decided yet means there is no branch to ask GitHub about at
8962    /// all — `find_external_merge` must return `None` without ever spawning
8963    /// `gh`, which this proves by never providing a real repository to spawn
8964    /// it in.
8965    #[tokio::test]
8966    async fn find_external_merge_returns_none_without_a_winner() {
8967        let state = RunState::new(
8968            PathBuf::from("/no/such/repo"),
8969            "main".to_owned(),
8970            "0000000000000000000000000000000000000000".to_owned(),
8971            "irrelevant".to_owned(),
8972            crate::config::Config::default(),
8973        );
8974        assert_eq!(find_external_merge(&state).await.unwrap(), None);
8975    }
8976
8977    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
8978        let mut run = RunState::new(
8979            PathBuf::from(repo),
8980            "main".to_owned(),
8981            "abcdef1234".to_owned(),
8982            "x".to_owned(),
8983            crate::config::Config::default(),
8984        );
8985        run.id = id.to_owned();
8986        run.status = status;
8987        run.driver_exited = true;
8988        run.pr = Some(crate::run::PrRecord {
8989            number: url.rsplit('/').next().unwrap().parse().unwrap(),
8990            url: url.to_owned(),
8991            state: state.to_owned(),
8992            checks: "red".to_owned(),
8993            round: 0,
8994            rounds: 2,
8995            red_at_merge: Vec::new(),
8996        });
8997        run.save_under(home).unwrap();
8998    }
8999
9000    fn recorded(home: &Path, id: &str) -> String {
9001        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9002        serde_json::from_str::<RunState>(&body)
9003            .unwrap()
9004            .pr
9005            .unwrap()
9006            .state
9007    }
9008
9009    const PR: &str = "https://github.com/o/r/pull/7";
9010
9011    #[test]
9012    fn write_through_updates_predecessors_and_siblings_only() {
9013        let tmp = tempfile::tempdir().unwrap();
9014        let h = tmp.path();
9015        pr_run(
9016            h,
9017            "20261004-100000-aaaa",
9018            "/repo/r",
9019            RunStatus::Superseded,
9020            PR,
9021            "open",
9022        );
9023        pr_run(
9024            h,
9025            "20261004-100100-bbbb",
9026            "/repo/r",
9027            RunStatus::Blocked,
9028            PR,
9029            "open",
9030        );
9031        // Not terminal: a driver may be writing it.
9032        pr_run(
9033            h,
9034            "20261004-100200-cccc",
9035            "/repo/r",
9036            RunStatus::Landing,
9037            PR,
9038            "open",
9039        );
9040        // Another repository's pull request with the same number.
9041        pr_run(
9042            h,
9043            "20261004-100300-dddd",
9044            "/repo/other",
9045            RunStatus::Blocked,
9046            "https://github.com/o/other/pull/7",
9047            "open",
9048        );
9049        // A different pull request of the same repository.
9050        pr_run(
9051            h,
9052            "20261004-100400-eeee",
9053            "/repo/r",
9054            RunStatus::Blocked,
9055            "https://github.com/o/r/pull/8",
9056            "open",
9057        );
9058        pr_run(
9059            h,
9060            "20261004-100500-ffff",
9061            "/repo/r",
9062            RunStatus::Merged,
9063            PR,
9064            "open",
9065        );
9066        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9067
9068        assert_eq!(
9069            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9070            2
9071        );
9072        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9073        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9074        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9075        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9076        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9077        // The source's own record is the caller's to write.
9078        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9079        // Idempotent.
9080        assert_eq!(
9081            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9082            0
9083        );
9084        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9085        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9086    }
9087
9088    #[test]
9089    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9090        let home = tempfile::tempdir().unwrap();
9091        let live_id = "20261004-100000-aaaa";
9092        let unknown_id = "20261004-100100-bbbb";
9093        for id in [live_id, unknown_id] {
9094            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9095            let mut s = RunState::load_under(id, home.path()).unwrap();
9096            s.driver_exited = false;
9097            if id == live_id {
9098                s.driver_pid = Some(std::process::id());
9099                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9100            }
9101            s.save_under(home.path()).unwrap();
9102        }
9103        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9104        assert_eq!(apply_pr_states(home.path(), &known), 0);
9105        for id in [live_id, unknown_id] {
9106            assert_eq!(recorded(home.path(), id), "open");
9107        }
9108    }
9109
9110    #[test]
9111    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9112        let tmp = tempfile::tempdir().unwrap();
9113        let h = tmp.path();
9114        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9115        pr_run(
9116            h,
9117            "20261004-100000-aaaa",
9118            "/repo/r",
9119            RunStatus::Superseded,
9120            &url(1),
9121            "open",
9122        );
9123        pr_run(
9124            h,
9125            "20261004-100100-bbbb",
9126            "/repo/r",
9127            RunStatus::Blocked,
9128            &url(2),
9129            "open",
9130        );
9131        pr_run(
9132            h,
9133            "20261004-100200-cccc",
9134            "/repo/r",
9135            RunStatus::Ready,
9136            &url(3),
9137            "open",
9138        );
9139        pr_run(
9140            h,
9141            "20261004-100300-dddd",
9142            "/repo/r",
9143            RunStatus::Ready,
9144            &url(4),
9145            "open",
9146        );
9147        pr_run(
9148            h,
9149            "20261004-100400-eeee",
9150            "/repo/r",
9151            RunStatus::Implementing,
9152            &url(1),
9153            "open",
9154        );
9155        assert_eq!(stale_open_prs(h).len(), 4);
9156
9157        let mut known = BTreeMap::new();
9158        known.insert(url(1), PrLifecycle::Merged);
9159        known.insert(url(2), PrLifecycle::Closed);
9160        known.insert(url(3), PrLifecycle::Open);
9161        // #4: the forge could not be read, so it has no answer.
9162        assert_eq!(apply_pr_states(h, &known), 2);
9163        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9164        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9165        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9166        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9167        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9168        assert_eq!(apply_pr_states(h, &known), 0);
9169    }
9170
9171    // --- the land loop against a scripted forge -------------------------
9172
9173    use std::collections::VecDeque;
9174    use std::sync::Mutex;
9175
9176    /// Answers views from a script (the last one repeats), merges from a
9177    /// queue, and records every call so a test can assert the order.
9178    struct Scripted {
9179        views: Mutex<VecDeque<Seen>>,
9180        merges: Mutex<VecDeque<(bool, String)>>,
9181        fix: Mutex<Option<Fixed>>,
9182        log: Mutex<Vec<&'static str>>,
9183        argvs: Mutex<Vec<Vec<String>>>,
9184        required: Mutex<Option<BTreeSet<String>>>,
9185        /// Set once a merge answered ok: the forge then reports `merged`,
9186        /// unless `queued` says the merge only entered a queue.
9187        merged: Mutex<bool>,
9188        queued: Mutex<bool>,
9189        /// Views fail once a merge answered ok.
9190        unreadable_after_merge: Mutex<bool>,
9191        /// Workflow runs asked to rerun, and whether that call fails.
9192        reruns: Mutex<Vec<String>>,
9193        rerun_fails: Mutex<bool>,
9194        /// Unix seconds; every poll moves it on by `POLL`.
9195        clock: Mutex<i64>,
9196    }
9197
9198    impl Scripted {
9199        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9200            Self {
9201                views: Mutex::new(views.into()),
9202                merges: Mutex::new(
9203                    merges
9204                        .into_iter()
9205                        .map(|(ok, m)| (ok, m.to_owned()))
9206                        .collect(),
9207                ),
9208                fix: Mutex::new(None),
9209                log: Mutex::new(Vec::new()),
9210                argvs: Mutex::new(Vec::new()),
9211                required: Mutex::new(None),
9212                merged: Mutex::new(false),
9213                queued: Mutex::new(false),
9214                unreadable_after_merge: Mutex::new(false),
9215                reruns: Mutex::new(Vec::new()),
9216                rerun_fails: Mutex::new(false),
9217                clock: Mutex::new(1_000),
9218            }
9219        }
9220        fn argvs(&self) -> Vec<Vec<String>> {
9221            self.argvs.lock().unwrap().clone()
9222        }
9223        fn calls(&self) -> Vec<&'static str> {
9224            self.log.lock().unwrap().clone()
9225        }
9226    }
9227
9228    impl Forge for Scripted {
9229        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9230            self.log.lock().unwrap().push("view");
9231            if *self.unreadable_after_merge.lock().unwrap()
9232                && !self.argvs.lock().unwrap().is_empty()
9233            {
9234                anyhow::bail!("forge unreachable");
9235            }
9236            let mut v = self.views.lock().unwrap();
9237            let mut seen = if v.len() > 1 {
9238                v.pop_front().unwrap()
9239            } else {
9240                v[0].clone()
9241            };
9242            if *self.merged.lock().unwrap() {
9243                seen.pr.state = PrLifecycle::Merged;
9244            }
9245            Ok(seen)
9246        }
9247        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9248            self.log.lock().unwrap().push("merge");
9249            self.argvs.lock().unwrap().push(argv.to_vec());
9250            let out = self
9251                .merges
9252                .lock()
9253                .unwrap()
9254                .pop_front()
9255                .expect("unscripted merge");
9256            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9257            {
9258                *self.merged.lock().unwrap() = true;
9259            }
9260            Ok(out)
9261        }
9262        async fn poll(&self) {
9263            self.log.lock().unwrap().push("poll");
9264            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9265        }
9266        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9267            self.log.lock().unwrap().push("rerun");
9268            self.reruns.lock().unwrap().push(run.to_owned());
9269            if *self.rerun_fails.lock().unwrap() {
9270                anyhow::bail!("gh run rerun: no");
9271            }
9272            Ok(())
9273        }
9274        fn now(&self) -> i64 {
9275            *self.clock.lock().unwrap()
9276        }
9277        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9278            self.required.lock().unwrap().clone()
9279        }
9280        async fn fix(
9281            &self,
9282            _state: &mut RunState,
9283            _pr: &PrState,
9284            _round: usize,
9285            _budget: usize,
9286            _reason: &str,
9287            _logs: &str,
9288        ) -> Result<Fixed> {
9289            self.log.lock().unwrap().push("fix");
9290            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9291        }
9292    }
9293
9294    const REFUSED: &str =
9295        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9296
9297    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9298        let mut pr = green_pr();
9299        pr.checks = checks;
9300        pr.blocking = Blocking::of(merge_state);
9301        if !comments {
9302            pr.review_comments.clear();
9303        }
9304        Seen {
9305            pr,
9306            title: "feat: x".to_owned(),
9307            failing_urls: Vec::new(),
9308            head: head.to_owned(),
9309            rollup_head: head.to_owned(),
9310            merge_state: merge_state.to_owned(),
9311            contexts: Vec::new(),
9312            base: "main".to_owned(),
9313        }
9314    }
9315
9316    fn landing_state() -> RunState {
9317        crate::run::pin_test_home();
9318        let mut state = run_state();
9319        state.config.graph.land_approval = false;
9320        state
9321    }
9322
9323    #[test]
9324    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9325        assert!(!awaiting_new_head(None, "aaa"));
9326        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9327        assert!(awaiting_new_head(Some("abc123"), "def456"));
9328        assert!(awaiting_new_head(Some("abc123"), ""));
9329    }
9330
9331    #[test]
9332    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9333        let open = |c, m: &str| seen("a", c, m, false);
9334        let table = [
9335            (None, false, Refused::Pending),
9336            (
9337                Some(open(Checks::Pending, "BLOCKED")),
9338                false,
9339                Refused::Pending,
9340            ),
9341            (
9342                Some(open(Checks::Unknown, "BLOCKED")),
9343                false,
9344                Refused::Pending,
9345            ),
9346            (
9347                Some(open(Checks::Green, "UNKNOWN")),
9348                false,
9349                Refused::Pending,
9350            ),
9351            (Some(open(Checks::Green, "")), false, Refused::Pending),
9352            (
9353                Some(open(Checks::Green, "BLOCKED")),
9354                false,
9355                Refused::Recheck,
9356            ),
9357            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9358        ];
9359        for (after, rechecked, want) in table {
9360            assert_eq!(classify_refusal(after.as_ref(), rechecked, "a"), want);
9361        }
9362        let mut closed = open(Checks::Green, "CLEAN");
9363        closed.pr.state = PrLifecycle::Closed;
9364        assert_eq!(classify_refusal(Some(&closed), false, "a"), Refused::Final);
9365    }
9366
9367    #[tokio::test]
9368    async fn a_normal_landing_merges_on_the_first_look() {
9369        let mut state = landing_state();
9370        let forge = Scripted::new(
9371            vec![seen("a", Checks::Green, "CLEAN", false)],
9372            vec![(true, "")],
9373        );
9374        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9375            .await
9376            .unwrap();
9377        // One fresh read, then the head-bound merge.
9378        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9379        assert_eq!(state.status, RunStatus::Merged);
9380    }
9381
9382    #[tokio::test]
9383    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9384        let mut state = landing_state();
9385        let forge = Scripted::new(
9386            vec![seen("a", Checks::Green, "CLEAN", false)],
9387            std::iter::repeat_n((true, ""), 100).collect(),
9388        );
9389        *forge.queued.lock().unwrap() = true;
9390        let task = async {
9391            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9392                .await
9393                .unwrap();
9394        };
9395        // Still open after the command succeeded: it keeps watching and
9396        // never records a merge (it stops at the wait ceiling instead).
9397        task.await;
9398        assert_ne!(state.status, RunStatus::Merged);
9399    }
9400
9401    #[tokio::test]
9402    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9403        let mut state = landing_state();
9404        let forge = Scripted::new(
9405            vec![seen("a", Checks::Green, "CLEAN", false)],
9406            std::iter::repeat_n((true, ""), 100).collect(),
9407        );
9408        *forge.unreadable_after_merge.lock().unwrap() = true;
9409        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9410            .await
9411            .ok();
9412        assert_ne!(state.status, RunStatus::Merged);
9413    }
9414
9415    #[tokio::test]
9416    async fn a_task_public_head_is_awaited_on_land_entry() {
9417        let mut state = landing_state();
9418        state.pr_head = Some("magi/task-abcd-1234".into());
9419        state.pr_pushed_head = Some("new".into());
9420        let forge = Scripted::new(
9421            vec![
9422                seen("old", Checks::Green, "CLEAN", false),
9423                seen("new", Checks::Green, "CLEAN", false),
9424            ],
9425            vec![(true, "")],
9426        );
9427        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9428            .await
9429            .unwrap();
9430        assert_eq!(
9431            forge.calls(),
9432            ["view", "poll", "view", "view", "merge", "view"]
9433        );
9434        assert_eq!(state.status, RunStatus::Merged);
9435    }
9436
9437    #[tokio::test]
9438    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9439        let mut state = landing_state();
9440        state.pr_taken_over_by = Some("next".into());
9441        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9442        assert!(
9443            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9444                .await
9445                .is_err()
9446        );
9447        assert!(forge.calls().is_empty());
9448    }
9449
9450    #[tokio::test]
9451    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9452        let mut state = landing_state();
9453        let forge = Scripted::new(
9454            vec![
9455                seen("old", Checks::Green, "CLEAN", true),
9456                // The forge has not moved to the new head yet: still green.
9457                seen("old", Checks::Green, "CLEAN", true),
9458                seen("new", Checks::Pending, "BLOCKED", true),
9459                seen("new", Checks::Green, "CLEAN", true),
9460            ],
9461            vec![(true, "")],
9462        );
9463        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9464            head: "NEW".to_owned(),
9465        });
9466        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9467            .await
9468            .unwrap();
9469        assert_eq!(
9470            forge.calls(),
9471            [
9472                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9473                "view"
9474            ]
9475        );
9476        assert_eq!(state.status, RunStatus::Merged);
9477    }
9478
9479    #[tokio::test]
9480    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9481        let mut state = landing_state();
9482        let forge = Scripted::new(
9483            vec![
9484                seen("old", Checks::Green, "CLEAN", true),
9485                seen("someone-elses", Checks::Green, "CLEAN", true),
9486            ],
9487            vec![],
9488        );
9489        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9490            head: "mine".to_owned(),
9491        });
9492        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9493            .await
9494            .unwrap();
9495        assert!(!forge.calls().contains(&"merge"));
9496        let why = state.merge.as_ref().unwrap().detail.clone();
9497        assert!(
9498            why.contains("mine") && why.contains("someone-elses"),
9499            "{why}"
9500        );
9501        assert_eq!(state.status, RunStatus::Blocked);
9502    }
9503
9504    #[tokio::test]
9505    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9506        let mut state = landing_state();
9507        let forge = Scripted::new(
9508            vec![
9509                seen("a", Checks::Green, "CLEAN", false),
9510                seen("a", Checks::Green, "CLEAN", false),
9511                seen("a", Checks::Pending, "BLOCKED", false),
9512                seen("a", Checks::Pending, "BLOCKED", false),
9513                seen("a", Checks::Green, "CLEAN", false),
9514            ],
9515            vec![(false, REFUSED), (true, "")],
9516        );
9517        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9518            .await
9519            .unwrap();
9520        assert_eq!(
9521            forge.calls(),
9522            [
9523                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9524                "view"
9525            ]
9526        );
9527        assert_eq!(state.status, RunStatus::Merged);
9528    }
9529
9530    #[tokio::test]
9531    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
9532        let mut state = landing_state();
9533        let forge = Scripted::new(
9534            vec![
9535                seen("a", Checks::Green, "CLEAN", false),
9536                seen("a", Checks::Green, "BLOCKED", false),
9537            ],
9538            vec![(false, REFUSED), (false, REFUSED)],
9539        );
9540        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9541            .await
9542            .unwrap();
9543        // One re-look is allowed for the forge's own lag, then it is final.
9544        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
9545        let why = state.merge.as_ref().unwrap().detail.clone();
9546        assert!(
9547            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
9548            "{why}"
9549        );
9550        assert_eq!(state.status, RunStatus::Blocked);
9551    }
9552
9553    #[test]
9554    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
9555        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
9556        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
9557        // The pull request is still on the commit before the push.
9558        assert_eq!(bound_head("old", "old", Some("new")), None);
9559        // The checks are the previous commit's.
9560        assert_eq!(bound_head("new", "old", Some("new")), None);
9561        assert_eq!(bound_head("new", "old", None), None);
9562        // Nothing readable.
9563        assert_eq!(bound_head("", "", None), None);
9564        assert_eq!(bound_head("", "", Some("new")), None);
9565        assert_eq!(bound_head("abc", "", None), None);
9566    }
9567
9568    fn view_json(head: &str) -> String {
9569        format!(
9570            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
9571            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
9572            "reviews":[],"comments":[]}}"#
9573        )
9574    }
9575
9576    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
9577        format!(
9578            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
9579            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
9580            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
9581            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
9582        )
9583    }
9584
9585    #[test]
9586    fn rollup_is_bound_to_the_commit_in_the_same_node() {
9587        // The view already points at the new head, but the node still answers
9588        // for the old commit with its red check: the head stays unbound.
9589        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
9590        assert_eq!(s.rollup_head, "old");
9591        assert_eq!(s.pr.checks, Checks::Red);
9592        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
9593        assert_eq!(s.failing_urls.len(), 1);
9594    }
9595
9596    #[test]
9597    fn checks_come_from_the_node_not_the_view() {
9598        let view = view_json("new").replace(
9599            r#""reviews""#,
9600            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
9601        );
9602        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
9603        assert_eq!(s.pr.checks, Checks::Green);
9604        assert!(s.pr.failing.is_empty());
9605        assert_eq!(
9606            bound_head(&s.head, &s.rollup_head, Some("new")),
9607            Some("new")
9608        );
9609    }
9610
9611    #[test]
9612    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
9613        for node in [
9614            None,
9615            Some("not json".to_owned()),
9616            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
9617            Some(node_json("new", "SUCCESS", true)),
9618        ] {
9619            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
9620            assert!(s.rollup_head.is_empty());
9621            assert_eq!(s.pr.checks, Checks::Unknown);
9622            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
9623        }
9624    }
9625
9626    #[test]
9627    fn the_merge_command_is_pinned_to_the_observed_head() {
9628        let argv = merge_argv_at(7, "feat: x", "deadbeef");
9629        let at = argv
9630            .iter()
9631            .position(|a| a == "--match-head-commit")
9632            .unwrap();
9633        assert_eq!(argv[at + 1], "deadbeef");
9634    }
9635
9636    #[tokio::test]
9637    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
9638        let mut state = landing_state();
9639        // The pull request is on the pushed head but the rollup is still the
9640        // previous commit's red, non-required result.
9641        let mut stale = seen("new", Checks::Red, "CLEAN", false);
9642        stale.rollup_head = "old".to_owned();
9643        let forge = Scripted::new(
9644            vec![seen("old", Checks::Green, "CLEAN", true), stale],
9645            vec![],
9646        );
9647        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9648            head: "new".to_owned(),
9649        });
9650        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9651            .await
9652            .unwrap();
9653        assert!(!forge.calls().contains(&"merge"));
9654        assert_eq!(state.status, RunStatus::Blocked);
9655        let why = state.merge.as_ref().unwrap().detail.clone();
9656        assert!(why.contains("new") && why.contains("old"), "{why}");
9657    }
9658
9659    #[test]
9660    fn a_refusal_read_against_another_commits_checks_is_pending() {
9661        let mut after = seen("a", Checks::Green, "BLOCKED", false);
9662        after.rollup_head = "old".to_owned();
9663        assert_eq!(classify_refusal(Some(&after), true, "a"), Refused::Pending);
9664    }
9665
9666    #[tokio::test]
9667    async fn a_matching_head_with_red_non_required_checks_still_merges() {
9668        let mut state = landing_state();
9669        let forge = Scripted::new(
9670            vec![seen("a", Checks::Red, "CLEAN", false)],
9671            vec![(true, "")],
9672        );
9673        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9674            .await
9675            .unwrap();
9676        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9677        assert_eq!(state.status, RunStatus::Merged);
9678    }
9679
9680    #[tokio::test]
9681    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
9682        let mut state = landing_state();
9683        let forge = Scripted::new(
9684            vec![
9685                seen("a", Checks::Green, "CLEAN", false),
9686                seen("a", Checks::Green, "CLEAN", false),
9687                // Re-viewed after the refusal: someone pushed.
9688                seen("b", Checks::Green, "BLOCKED", false),
9689                seen("b", Checks::Green, "CLEAN", false),
9690            ],
9691            vec![(false, REFUSED), (true, "")],
9692        );
9693        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9694            .await
9695            .unwrap();
9696        assert_eq!(
9697            forge.calls(),
9698            [
9699                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
9700            ]
9701        );
9702        assert_eq!(state.status, RunStatus::Merged);
9703    }
9704
9705    fn merged_view(head: &str) -> Seen {
9706        let mut m = seen(head, Checks::Green, "CLEAN", false);
9707        m.pr.state = PrLifecycle::Merged;
9708        m
9709    }
9710
9711    fn has(argv: &[String], flag: &str) -> bool {
9712        argv.iter().any(|a| a == flag)
9713    }
9714
9715    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
9716        let at = argv.iter().position(|a| a == flag)?;
9717        argv.get(at + 1).map(String::as_str)
9718    }
9719
9720    const URL: &str = "https://github.com/o/r/pull/42";
9721
9722    #[tokio::test]
9723    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
9724        let mut state = landing_state();
9725        let forge = Scripted::new(
9726            vec![
9727                seen("abc", Checks::Green, "CLEAN", false),
9728                seen("abc", Checks::Green, "CLEAN", false),
9729            ],
9730            vec![(true, "")],
9731        );
9732        land_with(&mut state, URL, &forge).await.unwrap();
9733        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9734        let argv = &forge.argvs()[0];
9735        assert!(has(argv, "--squash") && has(argv, "--subject"));
9736        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
9737        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
9738        assert_eq!(state.status, RunStatus::Merged);
9739        assert!(state.land_armed_head.is_none());
9740    }
9741
9742    #[tokio::test]
9743    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
9744        let mut state = landing_state();
9745        state.land_armed_head = Some("a".to_owned());
9746        let forge = Scripted::new(
9747            vec![seen("a", Checks::Green, "CLEAN", false)],
9748            vec![(true, ""), (true, "")],
9749        );
9750        land_with(&mut state, URL, &forge).await.unwrap();
9751        let argvs = forge.argvs();
9752        assert!(has(&argvs[0], "--disable-auto"));
9753        assert!(!has(&argvs[1], "--auto"));
9754        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
9755        assert_eq!(state.status, RunStatus::Merged);
9756        assert!(state.land_armed_head.is_none());
9757    }
9758
9759    #[tokio::test]
9760    async fn an_approval_never_carries_over_to_a_new_head() {
9761        crate::run::pin_test_home();
9762        let mut state = run_state();
9763        state.config.graph.land_approval = true;
9764        let pr = green_pr();
9765        let store = ask::Questions::open();
9766
9767        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
9768            .await
9769            .unwrap();
9770        let mut q = store
9771            .list()
9772            .into_iter()
9773            .find(|q| q.run == state.id)
9774            .unwrap();
9775        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9776        store.put(&mut q).unwrap();
9777        assert_eq!(
9778            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
9779                .await
9780                .unwrap(),
9781            ApprovalGate::Approved,
9782            "the same head keeps its approval"
9783        );
9784
9785        // A new head is a new question, not the old word.
9786        assert_eq!(
9787            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9788                .await
9789                .unwrap(),
9790            ApprovalGate::Pending
9791        );
9792        let all: Vec<_> = store
9793            .list()
9794            .into_iter()
9795            .filter(|q| q.run == state.id)
9796            .collect();
9797        assert_eq!(all.len(), 2);
9798
9799        // A question recorded before heads were tracked is not reused either.
9800        state.land_approval = None;
9801        assert_eq!(
9802            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
9803                .await
9804                .unwrap(),
9805            ApprovalGate::Pending
9806        );
9807        let open = store
9808            .list()
9809            .into_iter()
9810            .filter(|q| q.run == state.id && q.status.open())
9811            .count();
9812        assert_eq!(open, 1, "the superseded question was retired");
9813    }
9814
9815    #[tokio::test]
9816    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
9817        let mut state = landing_state();
9818        let forge = Scripted::new(
9819            vec![
9820                seen("a", Checks::Green, "CLEAN", false),
9821                // The fresh read before the merge: someone pushed.
9822                seen("b", Checks::Green, "CLEAN", false),
9823            ],
9824            vec![(true, "")],
9825        );
9826        land_with(&mut state, URL, &forge).await.unwrap();
9827        let argvs = forge.argvs();
9828        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
9829        assert!(!has(&argvs[0], "--auto"));
9830        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
9831        assert_eq!(state.status, RunStatus::Merged);
9832    }
9833
9834    fn passing(label: &str) -> CheckInfo {
9835        CheckInfo {
9836            label: label.to_owned(),
9837            verdict: Verdict::Pass,
9838            required: Some(false),
9839            run: None,
9840        }
9841    }
9842
9843    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
9844        (label.to_owned(), run.map(str::to_owned))
9845    }
9846
9847    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
9848        LandReruns {
9849            head: head.to_owned(),
9850            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
9851        }
9852    }
9853
9854    #[test]
9855    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
9856        let f = [failing("test (macos)", Some("7"))];
9857        assert_eq!(
9858            rerun_step(&f, None, "h", 100),
9859            RerunStep::Rerun(vec!["7".to_owned()])
9860        );
9861        let rec = reran("h", &[("7", 100)]);
9862        let grace = crate::release_watch::RERUN_GRACE;
9863        assert_eq!(
9864            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
9865            RerunStep::Grace
9866        );
9867        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
9868    }
9869
9870    #[test]
9871    fn a_failure_without_a_run_id_goes_to_the_fixer() {
9872        let f = [failing("a", Some("7")), failing("ci/external", None)];
9873        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
9874        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
9875    }
9876
9877    #[test]
9878    fn a_moved_head_forgets_the_reruns() {
9879        let f = [failing("a", Some("7"))];
9880        let rec = reran("old", &[("7", 100)]);
9881        assert_eq!(
9882            rerun_step(&f, Some(&rec), "new", 101),
9883            RerunStep::Rerun(vec!["7".to_owned()])
9884        );
9885    }
9886
9887    #[test]
9888    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
9889        let f = [
9890            failing("a", Some("7")),
9891            failing("b", Some("7")),
9892            failing("c", Some("8")),
9893        ];
9894        assert_eq!(
9895            rerun_step(&f, None, "h", 0),
9896            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
9897        );
9898        let rec = reran("h", &[("7", 0)]);
9899        assert_eq!(
9900            rerun_step(&f, Some(&rec), "h", 1),
9901            RerunStep::Rerun(vec!["8".to_owned()])
9902        );
9903    }
9904
9905    #[test]
9906    fn only_blocking_failures_are_candidates_for_a_rerun() {
9907        let check = |label: &str, verdict, required, run: &str| CheckInfo {
9908            label: label.to_owned(),
9909            verdict,
9910            required,
9911            run: Some(run.to_owned()),
9912        };
9913        let got = rerunnable(&[
9914            check("passed", Verdict::Pass, Some(true), "1"),
9915            check("optional", Verdict::Fail, Some(false), "2"),
9916            check("required", Verdict::Fail, Some(true), "3"),
9917            check("unknown", Verdict::Fail, None, "4"),
9918        ]);
9919        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
9920        assert_eq!(labels, ["required", "unknown"]);
9921    }
9922
9923    fn red(head: &str, run: Option<&str>) -> Seen {
9924        let mut s = seen(head, Checks::Red, "BLOCKED", false);
9925        s.pr.failing = vec!["test (macos)".to_owned()];
9926        s.contexts = vec![CheckInfo {
9927            label: "test (macos)".to_owned(),
9928            verdict: Verdict::Fail,
9929            required: Some(true),
9930            run: run.map(str::to_owned),
9931        }];
9932        s
9933    }
9934
9935    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
9936
9937    #[tokio::test]
9938    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
9939        let mut state = landing_state();
9940        let forge = Scripted::new(
9941            vec![
9942                red("a", Some("7")),
9943                seen("a", Checks::Pending, "BLOCKED", false),
9944                seen("a", Checks::Green, "CLEAN", false),
9945            ],
9946            vec![(true, "")],
9947        );
9948        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
9949        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
9950        assert!(!forge.calls().contains(&"fix"));
9951        assert_eq!(state.status, RunStatus::Merged);
9952        assert_eq!(state.pr.as_ref().unwrap().round, 0);
9953        let argvs = forge.argvs();
9954        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
9955        assert!(
9956            state
9957                .events
9958                .iter()
9959                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
9960        );
9961    }
9962
9963    #[tokio::test]
9964    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
9965        let mut state = landing_state();
9966        let forge = Scripted::new(
9967            vec![
9968                red("a", Some("7")),
9969                seen("a", Checks::Green, "CLEAN", false),
9970            ],
9971            vec![(true, "")],
9972        );
9973        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
9974        // The red view repeats for the grace, then the fixer is asked once.
9975        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
9976        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
9977        let calls = forge.calls();
9978        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
9979        let fix_at = calls.iter().position(|c| *c == "fix");
9980        assert!(fix_at.is_none_or(|f| f > rerun_at));
9981    }
9982
9983    #[tokio::test]
9984    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
9985        let mut state = landing_state();
9986        let mut v = red("a", Some("7"));
9987        v.pr.checks = Checks::Pending;
9988        v.contexts.push(CheckInfo {
9989            label: "slow".to_owned(),
9990            verdict: Verdict::Pending,
9991            required: Some(true),
9992            run: Some("8".to_owned()),
9993        });
9994        let forge = Scripted::new(
9995            vec![v, seen("a", Checks::Green, "CLEAN", false)],
9996            vec![(true, "")],
9997        );
9998        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
9999        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10000        assert_eq!(state.status, RunStatus::Merged);
10001    }
10002
10003    #[test]
10004    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10005        let mut v = red("a", Some("7"));
10006        v.contexts.push(CheckInfo {
10007            label: "same run, still going".to_owned(),
10008            verdict: Verdict::Pending,
10009            required: Some(true),
10010            run: Some("7".to_owned()),
10011        });
10012        assert!(rerunnable(&v.contexts).is_empty());
10013    }
10014
10015    #[tokio::test]
10016    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10017        let mut state = landing_state();
10018        state.config.graph.land_rounds = 0;
10019        let forge = Scripted::new(
10020            vec![
10021                red("a", Some("7")),
10022                seen("a", Checks::Green, "CLEAN", false),
10023            ],
10024            vec![(true, "")],
10025        );
10026        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10027        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10028        assert_eq!(state.status, RunStatus::Merged);
10029        assert!(!forge.calls().contains(&"fix"));
10030        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10031    }
10032
10033    #[tokio::test]
10034    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10035        let mut state = landing_state();
10036        state.config.graph.land_rounds = 1;
10037        let forge = Scripted::new(
10038            vec![
10039                seen("a", Checks::Green, "CLEAN", true),
10040                red("b", Some("7")),
10041                seen("b", Checks::Green, "CLEAN", false),
10042            ],
10043            vec![(true, "")],
10044        );
10045        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10046            head: "b".to_owned(),
10047        });
10048        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10049        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10050        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10051        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10052        assert_eq!(state.status, RunStatus::Merged);
10053        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10054        assert_eq!(
10055            value_of(&forge.argvs()[0], "--match-head-commit"),
10056            Some("b")
10057        );
10058    }
10059
10060    #[tokio::test]
10061    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10062        let mut state = landing_state();
10063        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10064        *forge.rerun_fails.lock().unwrap() = true;
10065        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10066        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10067        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10068        assert_eq!(
10069            forge.calls().iter().filter(|c| **c == "fix").count(),
10070            1,
10071            "{:?}",
10072            forge.calls()
10073        );
10074    }
10075
10076    #[tokio::test]
10077    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10078        let mut state = landing_state();
10079        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10080        // The record was written just before the previous pass died.
10081        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10082        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10083        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10084        assert!(forge.reruns.lock().unwrap().is_empty());
10085    }
10086
10087    #[tokio::test]
10088    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10089        let mut state = landing_state();
10090        state.config.graph.rerun_flaky_checks = false;
10091        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10092        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10093        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10094        assert!(forge.reruns.lock().unwrap().is_empty());
10095        assert!(forge.calls().contains(&"fix"));
10096
10097        let mut state = landing_state();
10098        let forge = Scripted::new(vec![red("a", None)], vec![]);
10099        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10100        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10101        assert!(forge.reruns.lock().unwrap().is_empty());
10102        assert!(forge.calls().contains(&"fix"));
10103    }
10104
10105    fn names(xs: &[&str]) -> BTreeSet<String> {
10106        xs.iter().map(|x| (*x).to_owned()).collect()
10107    }
10108
10109    #[test]
10110    fn a_required_check_the_rollup_never_listed_is_named() {
10111        let req = names(&["build"]);
10112        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10113        assert!(why.contains("never reported: build"), "{why}");
10114        assert!(!why.contains("probably waiting for a review"), "{why}");
10115    }
10116
10117    #[test]
10118    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10119        let why = waiting_on("BLOCKED", &[passing("review")], None);
10120        assert!(why.contains("could not be read"), "{why}");
10121        assert!(!why.contains("probably waiting for a review"), "{why}");
10122    }
10123
10124    #[test]
10125    fn all_required_reported_keeps_the_review_guess() {
10126        let req = names(&["build"]);
10127        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10128        assert!(why.contains("probably waiting for a review"), "{why}");
10129        assert!(!why.contains("never reported"), "{why}");
10130    }
10131
10132    #[test]
10133    fn required_names_match_the_rollup_ignoring_case_only() {
10134        let req = names(&["Build"]);
10135        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10136        assert!(!why.contains("never reported"), "{why}");
10137    }
10138
10139    #[test]
10140    fn required_contexts_are_read_from_protection_and_rulesets() {
10141        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10142        assert_eq!(
10143            parse_classic_required(classic),
10144            Some(names(&["build", "lint"]))
10145        );
10146        let rules = r#"[{"type":"pull_request","parameters":{}},
10147            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10148        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10149        assert_eq!(parse_ruleset_required("nope"), None);
10150        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10151    }
10152
10153    #[test]
10154    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10155        let shown = BTreeSet::new();
10156        let ok = seen("a", Checks::Green, "CLEAN", false);
10157        let guard = |s: Option<&Seen>| direct_merge_is_safe(s, "A", &shown, 0, 4, Duration::ZERO);
10158        assert!(guard(Some(&ok)));
10159        assert!(!guard(None));
10160        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10161        let mut stale = ok.clone();
10162        stale.rollup_head = "old".to_owned();
10163        assert!(!guard(Some(&stale)));
10164        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10165        assert!(!guard(Some(&merged_view("a"))));
10166    }
10167
10168    #[test]
10169    fn the_rollup_node_carries_whether_each_check_is_required() {
10170        let node = node_json("new", "SUCCESS", false)
10171            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10172        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10173        assert_eq!(s.contexts.len(), 1);
10174        assert_eq!(s.contexts[0].required, Some(true));
10175        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10176        assert_eq!(s.contexts[0].required, None);
10177    }
10178
10179    #[tokio::test]
10180    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10181        let mut state = landing_state();
10182        state.land_armed_head = Some("a".to_owned());
10183        let forge = Scripted::new(
10184            vec![seen("a", Checks::Green, "CLEAN", true)],
10185            vec![(false, "disable exploded")],
10186        );
10187        land_with(&mut state, URL, &forge).await.unwrap();
10188        assert!(!forge.calls().contains(&"fix"));
10189        assert_eq!(state.status, RunStatus::Blocked);
10190        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10191        let why = state.merge.as_ref().unwrap().detail.clone();
10192        assert!(why.contains("disable exploded"), "{why}");
10193    }
10194}