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    /// The repository explicitly disables CI expectations with `[merge] ci = "none"`.
347    /// Ignore all check statuses while retaining conflicts and review comments.
348    Absent,
349}
350
351/// [`decide`] with the CI expectation made explicit. `Absent` ignores the
352/// rollup, but still requires conflicts and review comments to be resolved.
353pub fn decide_with(
354    pr: &PrState,
355    round: usize,
356    budget: usize,
357    waited: Duration,
358    ci: CiExpectation,
359) -> Step {
360    match pr.state {
361        PrLifecycle::Merged => return Step::Done { merged: true },
362        PrLifecycle::Closed => return Step::Done { merged: false },
363        PrLifecycle::Open => {}
364    }
365
366    // Before the checks: every check on a branch that cannot land is an
367    // answer about a state that cannot land.
368    if pr.blocking == Blocking::Conflict {
369        return Step::Rebase;
370    }
371
372    let spent = round >= budget;
373    let checks = match ci {
374        CiExpectation::Expected => pr.checks,
375        CiExpectation::Absent => Checks::Green,
376    };
377    match checks {
378        Checks::Pending => Step::Wait,
379        Checks::Unknown if waited < CHECKS_GRACE => Step::Wait,
380        Checks::Unknown => Step::GiveUp {
381            reason: format!(
382                "no check status is readable on the pull request after {} minute(s); \
383                 refusing to merge on a guess",
384                CHECKS_GRACE.as_secs() / 60
385            ),
386        },
387        // Red, but the forge says it does not stand in the way: the failing
388        // checks are ones this repository chose not to require. Spending a fix
389        // round on them asks an agent to repair something nobody is gating on
390        // - and pull request 37's only red check was an *action* that could
391        // not fetch its own binary. Merge, and name them so the record is
392        // honest about what was red when it landed.
393        Checks::Red if pr.blocking == Blocking::Unsaid && waited < CHECKS_GRACE => Step::Wait,
394        Checks::Red if !pr.blocking.stops_a_merge() && pr.review_comments.is_empty() => Step::Merge,
395        Checks::Red => {
396            let what = format!(
397                "{} check(s) failing: {}",
398                pr.failing.len(),
399                pr.failing.join(", ")
400            );
401            if spent {
402                Step::GiveUp {
403                    reason: format!("{what} — still red after {budget} fix round(s)"),
404                }
405            } else {
406                Step::Fix { reason: what }
407            }
408        }
409        Checks::Green if pr.review_comments.is_empty() => Step::Merge,
410        Checks::Green => {
411            let what = format!(
412                "{} but {} review comment(s) are unresolved: {}",
413                if ci == CiExpectation::Absent {
414                    "CI is not expected"
415                } else {
416                    "checks are green"
417                },
418                pr.review_comments.len(),
419                authors(&pr.review_comments)
420            );
421            if spent {
422                Step::GiveUp {
423                    reason: format!("{what} — still unresolved after {budget} fix round(s)"),
424                }
425            } else {
426                Step::Fix { reason: what }
427            }
428        }
429    }
430}
431
432/// Distinct comment authors, in the order they first appear.
433fn authors(comments: &[ReviewComment]) -> String {
434    let mut seen: Vec<&str> = Vec::new();
435    for c in comments {
436        if !seen.contains(&c.author.as_str()) {
437            seen.push(&c.author);
438        }
439    }
440    seen.join(", ")
441}
442
443/// The argv magi merges with, minus the program name.
444///
445/// `--subject` is the point of this function existing: see the module docs.
446pub fn merge_argv(number: u64, subject: &str) -> Vec<String> {
447    vec![
448        "pr".to_owned(),
449        "merge".to_owned(),
450        number.to_string(),
451        "--squash".to_owned(),
452        "--delete-branch".to_owned(),
453        "--subject".to_owned(),
454        subject.to_owned(),
455    ]
456}
457
458/// [`merge_argv`] pinned to the commit the decision was made about.
459///
460/// `--match-head-commit` makes the forge refuse the merge if the branch moved
461/// after it was observed, so a push landing between the look and the merge is
462/// never merged unseen.
463pub fn merge_argv_at(number: u64, subject: &str, head: &str) -> Vec<String> {
464    let mut argv = merge_argv(number, subject);
465    argv.push("--match-head-commit".to_owned());
466    argv.push(head.to_owned());
467    argv
468}
469
470/// Take auto-merge back. magi no longer arms auto-merge, but a run recorded by
471/// an older build may still have one standing on the forge, so the disarm
472/// paths (and `RunState::land_armed_head`) stay. Run before anything that changes the head, so an
473/// armed merge never outlives the commit that was approved for it.
474pub fn disable_automerge_argv(number: u64) -> Vec<String> {
475    ["pr", "merge", &number.to_string(), "--disable-auto"]
476        .map(str::to_owned)
477        .to_vec()
478}
479
480/// May the direct merge go ahead, judged from a fresh read?
481///
482/// The pull request must still be open on the approved head, the checks must
483/// have been read for that very head, and the policy must still say merge.
484/// Pure, so the head guard is asserted without a forge.
485fn direct_merge_is_safe(
486    fresh: Option<&Seen>,
487    approved_head: &str,
488    shown: &BTreeSet<String>,
489    round: usize,
490    budget: usize,
491    waited: Duration,
492    ci: CiExpectation,
493) -> bool {
494    let Some(fresh) = fresh else {
495        return false;
496    };
497    if fresh.pr.state != PrLifecycle::Open {
498        return false;
499    }
500    let Some(bound) = bound_head(&fresh.head, &fresh.rollup_head, None) else {
501        return false;
502    };
503    if !bound.eq_ignore_ascii_case(approved_head) {
504        return false;
505    }
506    let mut pr = fresh.pr.clone();
507    pr.review_comments.retain(|c| !shown.contains(&c.body));
508    decide_with(&pr, round, budget, waited, ci) == Step::Merge
509}
510
511/// What GitHub is still waiting for, for the stop reason when an armed merge
512/// does not happen in time. No I/O.
513///
514/// Required checks that are pending or failing are named. A check whose
515/// required-ness could not be read is never assumed optional: every unsettled
516/// check is listed and the reason says so.
517fn waiting_on(
518    merge_state: &str,
519    contexts: &[CheckInfo],
520    required_set: Option<&BTreeSet<String>>,
521) -> String {
522    let state = if merge_state.is_empty() {
523        "unknown"
524    } else {
525        merge_state
526    };
527    let tag = |c: &CheckInfo| match c.verdict {
528        Verdict::Fail => "failed",
529        _ => "pending",
530    };
531    let unsettled: Vec<&CheckInfo> = contexts
532        .iter()
533        .filter(|c| c.verdict != Verdict::Pass)
534        .collect();
535    let required: Vec<String> = unsettled
536        .iter()
537        .filter(|c| c.required == Some(true))
538        .map(|c| format!("{} ({})", c.label, tag(c)))
539        .collect();
540    let unknown: Vec<String> = unsettled
541        .iter()
542        .filter(|c| c.required.is_none())
543        .map(|c| format!("{} ({})", c.label, tag(c)))
544        .collect();
545    // Required contexts the rollup never listed: nothing reported them, so no
546    // pending/failing entry exists to name. Matched case-insensitively against
547    // the labels as the rollup spells them, and no further.
548    let never: Vec<&str> = required_set
549        .map(|set| {
550            set.iter()
551                .filter(|name| !contexts.iter().any(|c| c.label.eq_ignore_ascii_case(name)))
552                .map(String::as_str)
553                .collect()
554        })
555        .unwrap_or_default();
556    let mut out = format!("merge state: {state}");
557    if !never.is_empty() {
558        let _ = write!(
559            out,
560            "; required checks never reported: {}",
561            never.join(", ")
562        );
563    }
564    if !required.is_empty() {
565        let _ = write!(
566            out,
567            "; required checks not passing: {}",
568            required.join(", ")
569        );
570    }
571    if !unknown.is_empty() {
572        let _ = write!(
573            out,
574            "; whether these are required could not be read, so they may be: {}",
575            unknown.join(", ")
576        );
577    }
578    if required.is_empty() && unknown.is_empty() && never.is_empty() {
579        if required_set.is_some() {
580            out.push_str(
581                "; no required check is pending, failing or unreported, so GitHub is probably \
582                 waiting for a review or another branch rule",
583            );
584        } else {
585            out.push_str(
586                "; the required check list could not be read, so a required check that was \
587                 never reported cannot be ruled out",
588            );
589        }
590    }
591    out
592}
593
594/// The squash subject to merge under.
595///
596/// The pull request title, unless it is empty or is a candidate branch's commit
597/// subject that leaked into the title - in which case the task's own first line
598/// is used, because `magi: candidate A (uncommitted work)` in `main` tells a
599/// reader nothing about what landed.
600pub fn merge_subject(pr_title: &str, instruction: &str) -> String {
601    let title = pr_title.trim();
602    if !title.is_empty() && !title.starts_with("magi: candidate") {
603        return title.to_owned();
604    }
605    let first = instruction
606        .lines()
607        .map(str::trim)
608        .find(|l| !l.is_empty())
609        .unwrap_or("magi: land the winning candidate");
610    first.trim_start_matches(['#', ' ']).to_owned()
611}
612
613/// The choice that lets the merge happen, verbatim as the owner taps it.
614pub const APPROVE: &str = "merge";
615
616/// The choice that leaves the pull request open.
617pub const HOLD: &str = "hold";
618
619/// A bounded repair followed by a new review and a fresh owner decision.
620pub const REPAIR: &str = "fix findings and re-review";
621
622/// Whether review evidence offers a meaningful repair choice. This does not
623/// decide whether owner approval is required.
624pub fn repair_available(state: &RunState) -> bool {
625    state.reviews.last().is_some_and(|r| {
626        state.open_findings().iter().any(|f| f.severity.blocks())
627            || r.final_votes()
628                .iter()
629                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
630    })
631}
632
633/// All applicable reasons for consulting the owner, independent of repair choices.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum ApprovalReason {
636    /// Universal owner approval is enabled for every merge.
637    EveryMerge,
638    /// Contested-merge approval is enabled and blocking findings remain open.
639    OpenBlockingFindings,
640    /// Contested-merge approval is enabled and a final reviewer vote rejects.
641    FinalReject,
642    /// An existing owner decision must be honored even if controls change.
643    ExistingDecision,
644    /// Merge repair requires a fresh owner decision, even on the same head.
645    AfterRepair,
646}
647
648/// Disabling fresh approval controls never bypasses an existing owner decision
649/// or the fresh decision owed after repair, even if repair kept the same head.
650pub fn approval_reasons(state: &RunState) -> Vec<ApprovalReason> {
651    let mut reasons = Vec::new();
652    if state.owner_hold(crate::config::OwnerHold::LandApproval) {
653        reasons.push(ApprovalReason::EveryMerge);
654    }
655    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
656        if state.open_findings().iter().any(|f| f.severity.blocks()) {
657            reasons.push(ApprovalReason::OpenBlockingFindings);
658        }
659        if state.reviews.last().is_some_and(|r| {
660            r.final_votes()
661                .iter()
662                .any(|(_, _, v)| *v == crate::verdict::ReviewVote::Reject)
663        }) {
664            reasons.push(ApprovalReason::FinalReject);
665        }
666    }
667    if state.land_approval.is_some() {
668        reasons.push(ApprovalReason::ExistingDecision);
669    }
670    if !state.merge_repairs.is_empty() {
671        reasons.push(ApprovalReason::AfterRepair);
672    }
673    reasons
674}
675
676/// Bound free-form approval evidence by characters, including an explicit
677/// ellipsis when abbreviated. Keep UTF-8 text intact in questions and panels.
678fn approval_excerpt(text: &str, max_chars: usize) -> String {
679    if text.chars().count() <= max_chars {
680        text.to_owned()
681    } else {
682        let mut excerpt: String = text.chars().take(max_chars.saturating_sub(1)).collect();
683        excerpt.push('…');
684        excerpt
685    }
686}
687
688/// Persisted verbatim in the question and panel at creation. Deputies and
689/// resumed questions use that snapshot, never reconstruct past policy from
690/// today's settings. Fixer claims and successful gates are not resolutions.
691fn approval_evidence(state: &RunState) -> String {
692    const TITLE_CHARS: usize = 100;
693    const REASON_CHARS: usize = 300;
694    let reason = |text: &str| approval_excerpt(text, REASON_CHARS);
695    let ja = words(&state.config.graph.language).html_lang == "ja";
696    let mut s = if ja {
697        "承認が必要な理由:\n"
698    } else {
699        "Approval required because:\n"
700    }
701    .to_owned();
702    for reason in approval_reasons(state) {
703        let label = match (reason, ja) {
704            (ApprovalReason::EveryMerge, false) => {
705                "land_approval=true: owner approval for every merge"
706            }
707            (ApprovalReason::EveryMerge, true) => {
708                "land_approval=true: すべてのマージに所有者の承認が必要"
709            }
710            (ApprovalReason::OpenBlockingFindings, false) => {
711                "hold_contested_merge=true: open Major-or-above internal findings"
712            }
713            (ApprovalReason::OpenBlockingFindings, true) => {
714                "hold_contested_merge=true: Major 以上の内部指摘が未解決"
715            }
716            (ApprovalReason::FinalReject, false) => {
717                "hold_contested_merge=true: final reviewer reject after reconsideration (even without Major findings)"
718            }
719            (ApprovalReason::FinalReject, true) => {
720                "hold_contested_merge=true: 再考後の最終票が reject(Major 指摘がなくても承認が必要)"
721            }
722            (ApprovalReason::ExistingDecision, false) => {
723                "existing owner decision: changing controls cannot bypass it"
724            }
725            (ApprovalReason::ExistingDecision, true) => {
726                "既存の所有者判断: 設定変更では迂回できません"
727            }
728            (ApprovalReason::AfterRepair, false) => {
729                "fresh owner approval after merge repair, even with an unchanged head"
730            }
731            (ApprovalReason::AfterRepair, true) => {
732                "マージ修復後の新しい所有者承認(head に変更がなくても必要)"
733            }
734        };
735        let control = match reason {
736            ApprovalReason::EveryMerge => Some(crate::config::OwnerHold::LandApproval),
737            ApprovalReason::OpenBlockingFindings | ApprovalReason::FinalReject => {
738                Some(crate::config::OwnerHold::HoldContestedMerge)
739            }
740            _ => None,
741        };
742        let source = control
743            .filter(|c| !c.base_value(&state.config.graph))
744            .and_then(|c| state.branch_owner_holds.iter().find(|h| h.control == c));
745        if let Some(source) = source {
746            if ja {
747                let _ = writeln!(
748                    s,
749                    "- {}=true(レビュー対象ブランチの {} 由来, {})",
750                    source.control.key(),
751                    source.file,
752                    source.head
753                );
754            } else {
755                let _ = writeln!(
756                    s,
757                    "- {}=true from the reviewed branch's {} ({})",
758                    source.control.key(),
759                    source.file,
760                    source.head
761                );
762            }
763        }
764        let _ = writeln!(s, "- {label}");
765    }
766    s.push_str(if ja {
767        "\n再考後の各レビュアーの最終票:\n"
768    } else {
769        "\nFinal reviewer votes after reconsideration:\n"
770    });
771    if let Some(round) = state.reviews.last() {
772        let votes = round.final_votes();
773        for r in &round.reviews {
774            let vote = votes
775                .iter()
776                .find(|(seat, _, _)| *seat == r.reviewer)
777                .map(|(_, _, v)| v.label())
778                .unwrap_or(if ja { "記録なし" } else { "not recorded" });
779            let _ = writeln!(s, "#{} ({}): {vote}", r.reviewer, r.agent);
780            let rv = round
781                .reconsideration
782                .iter()
783                .find(|rv| rv.reviewer == r.reviewer);
784            if let Some(rv) = rv.filter(|rv| rv.vote.is_some()) {
785                let initial = r.vote.map(|v| v.label()).unwrap_or("?");
786                let _ = writeln!(
787                    s,
788                    "  {}: {initial} → {vote}",
789                    if ja { "再考" } else { "reconsideration" }
790                );
791                let _ = writeln!(
792                    s,
793                    "  {}: {}",
794                    if ja { "理由" } else { "reason" },
795                    reason(if rv.reason.trim().is_empty() {
796                        if ja { "記録なし" } else { "not recorded" }
797                    } else {
798                        &rv.reason
799                    })
800                );
801            } else {
802                let _ = writeln!(
803                    s,
804                    "  {}: {}",
805                    if ja {
806                        "初回票を保持・理由"
807                    } else {
808                        "initial vote retained; reason"
809                    },
810                    reason(if r.summary.trim().is_empty() {
811                        if ja { "記録なし" } else { "not recorded" }
812                    } else {
813                        &r.summary
814                    })
815                );
816                if let Some(rv) = rv {
817                    let _ = writeln!(
818                        s,
819                        "  {}: {}",
820                        if ja {
821                            "再投票の回答なし"
822                        } else {
823                            "no reconsideration vote"
824                        },
825                        reason(rv.failed.as_deref().unwrap_or(if ja {
826                            "理由の記録なし"
827                        } else {
828                            "reason not recorded"
829                        }))
830                    );
831                }
832            }
833            if let Some(failed) = &r.failed {
834                let _ = writeln!(
835                    s,
836                    "  {}: {}",
837                    if ja { "回答なし" } else { "no answer" },
838                    reason(failed)
839                );
840            }
841        }
842    } else {
843        s.push_str(if ja {
844            "レビュー記録なし\n"
845        } else {
846            "No review recorded\n"
847        });
848    }
849    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" });
850    let findings = state.last_round_findings();
851    for f in &findings {
852        let _ = writeln!(
853            s,
854            "[{}] {} {}:{}: {}\n{}",
855            f.id,
856            f.severity,
857            f.file.as_deref().unwrap_or("?"),
858            f.line.map(|l| l.to_string()).unwrap_or_else(|| "?".into()),
859            approval_excerpt(&f.title, TITLE_CHARS),
860            reason(&f.detail)
861        );
862        let mut evidence = false;
863        for fix in state.reviews.iter().filter_map(|r| r.fix.as_ref()).chain(
864            state
865                .operator_fixes
866                .iter()
867                .filter_map(|request| request.fix.as_ref()),
868        ) {
869            if fix.addressed.contains(&f.id) {
870                evidence = true;
871                let _ = writeln!(
872                    s,
873                    "  {} ({}): {}",
874                    if ja {
875                        "採用申告(解決未確認)"
876                    } else {
877                        "claimed addressed (resolution unconfirmed)"
878                    },
879                    fix.agent,
880                    reason(&fix.notes)
881                );
882            }
883            for rejection in fix.rejected.iter().filter(|r| r.id == f.id) {
884                evidence = true;
885                let _ = writeln!(
886                    s,
887                    "  {}: {}",
888                    if ja { "不採用理由" } else { "declined" },
889                    reason(&rejection.why)
890                );
891            }
892        }
893        if !evidence {
894            s.push_str(if ja {
895                "  採用・解決確認の記録なし\n"
896            } else {
897                "  No adoption or resolution evidence recorded\n"
898            });
899        }
900    }
901    if findings.is_empty() {
902        s.push_str(if ja {
903            "最終ラウンドに指摘の記録なし\n"
904        } else {
905            "No findings recorded in the last round\n"
906        });
907    }
908    s
909}
910
911/// Shared choices for web, CLI and consultation questions.
912pub fn approval_choices(state: &RunState) -> Vec<String> {
913    let mut choices = vec![
914        APPROVE.to_owned(),
915        HOLD.to_owned(),
916        REPAIR.to_owned(),
917        crate::merge_decision::WITHDRAW.to_owned(),
918        crate::merge_decision::DISMISS.to_owned(),
919    ];
920    choices.extend(
921        state
922            .config
923            .agents
924            .iter()
925            .map(|a| format!("{}{id}", crate::merge_decision::REIMPLEMENT, id = a.id)),
926    );
927    choices
928}
929
930/// Extend unanswered legacy cards in place. Answered cards keep their choices;
931/// a legacy repair answer gains only its missing dispatch action.
932pub fn upgrade_repair_choice(q: &mut ask::Question, state: &RunState) -> bool {
933    if q.node != APPROVAL_NODE
934        || !state
935            .land_approval
936            .as_ref()
937            .is_some_and(|a| a.question == q.id)
938    {
939        return false;
940    }
941    let before = (q.choices.clone(), q.actions.clone(), q.merge_target.clone());
942    if q.status.open() {
943        // Older cards attached Resume to hold; a hold must remain answerable
944        // without dispatching a question action.
945        q.actions.remove(HOLD);
946        for choice in approval_choices(state) {
947            if !q.choices.contains(&choice) {
948                q.choices.push(choice);
949            }
950        }
951        if q.merge_target.is_none() {
952            q.merge_target = state.pr.as_ref().map(|pr| crate::merge_decision::Target {
953                pr: pr.url.clone(),
954                head: state.land_approval.as_ref().unwrap().head.clone(),
955                language: state.config.graph.language.clone(),
956            });
957        }
958    }
959    for choice in q.choices.clone() {
960        if (q.status.open()
961            && crate::merge_decision::stops(&choice)
962            && choice != HOLD
963            && choice != crate::merge_decision::DISMISS)
964            || (choice == REPAIR && (q.status.open() || q.resolution().as_deref() == Some(REPAIR)))
965        {
966            q.actions.insert(
967                choice,
968                ask::ChoiceAction::Resume {
969                    run: state.id.clone(),
970                },
971            );
972        }
973    }
974    before != (q.choices.clone(), q.actions.clone(), q.merge_target.clone())
975}
976
977/// Graph node recorded on the approval question.
978///
979/// The phone keys its high-stakes card off this rather than off the choice
980/// strings, so renaming a button cannot silently downgrade the card that
981/// guards the one irreversible action magi takes.
982pub const APPROVAL_NODE: &str = "land-approval";
983
984/// Unified diff lines carried in the panel before it is truncated.
985///
986/// Four hundred: the panel is read on a 390px phone, where a diff line often
987/// wraps to two rows, so this is already a few thousand rows of scrolling -
988/// past that nobody is reading, and the bytes still count against the panel's
989/// 8 MiB cap. A larger diff is not hidden: the note says how many lines were
990/// cut and which worktree holds the whole patch.
991pub const DIFF_MAX_LINES: usize = 400;
992
993/// What the owner's answer to the approval question means.
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995pub enum Approval {
996    /// The owner said [`APPROVE`]. Merge.
997    Merge,
998    /// Repair the existing PR, then ask again; never approval to merge.
999    Repair,
1000    /// Anything else, including silence. Leave the pull request open.
1001    Hold,
1002}
1003
1004/// Read the owner's answer, where `None` is an unanswered question.
1005///
1006/// Silence is a hold. A timed-out question means the owner never saw it or
1007/// never decided, and defaulting an irreversible merge to "yes" would make this
1008/// gate worse than no gate at all: it would merge unattended while claiming to
1009/// have asked. Only the exact [`APPROVE`] choice merges, so an answer this
1010/// function does not recognise holds too.
1011pub fn approval(answer: Option<&str>) -> Approval {
1012    match answer {
1013        Some(a) if a.trim().eq_ignore_ascii_case(APPROVE) => Approval::Merge,
1014        Some(a) if a.trim() == REPAIR => Approval::Repair,
1015        _ => Approval::Hold,
1016    }
1017}
1018
1019/// What [`approval_gate`] found on one check of the owner's merge decision.
1020#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1021enum ApprovalGate {
1022    /// The owner said [`APPROVE`]. Merge.
1023    Approved,
1024    /// The owner explicitly requested repair, without authorizing a merge.
1025    Repair,
1026    /// The owner said anything else, the question timed out, or it was
1027    /// closed with no decision recorded.
1028    Held,
1029    /// Filed and still waiting - the caller parks rather than blocking on it.
1030    Pending,
1031}
1032
1033/// Escape text for HTML, including both quote characters.
1034///
1035/// Every string in the panel is agent-influenced: a branch name, a file path, a
1036/// commit subject, a review comment. The sandboxed frame stops such text from
1037/// *running*, but it does not stop a `<` from ending the document early or a
1038/// `"` from ending an attribute and inventing a new one - the panel would then
1039/// render a lie, or not render at all. Both quotes are escaped because the same
1040/// function is used inside attributes, where remembering which quote style the
1041/// caller used is one mistake away from an injected attribute.
1042fn esc(s: &str) -> String {
1043    let mut out = String::with_capacity(s.len());
1044    for c in s.chars() {
1045        match c {
1046            '&' => out.push_str("&amp;"),
1047            '<' => out.push_str("&lt;"),
1048            '>' => out.push_str("&gt;"),
1049            '"' => out.push_str("&quot;"),
1050            '\'' => out.push_str("&#39;"),
1051            _ => out.push(c),
1052        }
1053    }
1054    out
1055}
1056
1057/// One row of the diffstat table.
1058#[derive(Debug, Clone, PartialEq, Eq)]
1059struct StatRow {
1060    path: String,
1061    /// `None` for a binary file, which `git` reports as `-`.
1062    added: Option<u64>,
1063    removed: Option<u64>,
1064}
1065
1066impl StatRow {
1067    /// Lines touched, for sorting. A binary file counts as zero rather than as
1068    /// unknown, which puts it at the bottom where it needs no attention.
1069    fn churn(&self) -> u64 {
1070        self.added.unwrap_or(0) + self.removed.unwrap_or(0)
1071    }
1072}
1073
1074/// Parse `git diff --numstat` into rows, biggest churn first.
1075///
1076/// `--numstat` and not `--stat`: the `+++---` bar in `--stat` is *scaled* to the
1077/// terminal width, so counting its characters would print fabricated numbers in
1078/// the one table an operator approves an irreversible action from.
1079fn parse_numstat(numstat: &str) -> Vec<StatRow> {
1080    let mut rows: Vec<StatRow> = numstat
1081        .lines()
1082        .filter_map(|line| {
1083            let mut parts = line.splitn(3, '\t');
1084            let added = parts.next()?.trim();
1085            let removed = parts.next()?.trim();
1086            let path = parts.next()?.trim();
1087            if path.is_empty() {
1088                return None;
1089            }
1090            Some(StatRow {
1091                path: path.to_owned(),
1092                added: added.parse().ok(),
1093                removed: removed.parse().ok(),
1094            })
1095        })
1096        .collect();
1097    // Path breaks the tie so the same change always renders the same table; an
1098    // operator comparing two panels should not see rows shuffle.
1099    rows.sort_by(|a, b| b.churn().cmp(&a.churn()).then_with(|| a.path.cmp(&b.path)));
1100    rows
1101}
1102
1103/// How one diff line is shown: a gutter character, a style, and the body to
1104/// print - which is the line minus its marker, so the marker appears exactly
1105/// once, in the gutter.
1106///
1107/// The gutter is why this exists at all. The operator may be colour blind, or
1108/// reading in sunlight with the screen dimmed, so an added line is never
1109/// distinguished by its background alone: `+` and `-` sit in a fixed column,
1110/// the same mark they already read in a terminal.
1111fn diff_row(line: &str) -> (&'static str, &'static str, &str) {
1112    if line.starts_with("+++") || line.starts_with("---") {
1113        (" ", "color:#57606a;font-weight:600", line)
1114    } else if let Some(body) = line.strip_prefix('+') {
1115        ("+", "background:#e6ffec;color:#0a3622", body)
1116    } else if let Some(body) = line.strip_prefix('-') {
1117        ("-", "background:#ffebe9;color:#5c1a17", body)
1118    } else if line.starts_with("@@") {
1119        ("~", "background:#eef2ff;color:#3730a3", line)
1120    } else if let Some(body) = line.strip_prefix(' ') {
1121        (" ", "", body)
1122    } else {
1123        (" ", "color:#57606a;font-weight:600", line)
1124    }
1125}
1126
1127/// The handful of words the approval panel says in its own voice.
1128///
1129/// magi's own text, not an agent's, so `[graph] language` has to reach it too:
1130/// the operator asked why the merge question spoke English on a repository
1131/// configured for Japanese, and "because that string is a literal in Rust" is
1132/// not an answer. Only the languages magi can actually check are translated;
1133/// anything else falls back to English rather than shipping a guess, and that
1134/// fallback is deliberate.
1135struct Words {
1136    html_lang: &'static str,
1137    task: &'static str,
1138    what_changed: &'static str,
1139    review_verdict: &'static str,
1140    reviewer: &'static str,
1141    reviewer_no_answer: &'static str,
1142    checks: &'static str,
1143    nothing_failing: &'static str,
1144    files_changed: &'static str,
1145    commits: &'static str,
1146    no_commits: &'static str,
1147    comments: &'static str,
1148    no_comments: &'static str,
1149    diff: &'static str,
1150    truncated: &'static str,
1151    lands_as: &'static str,
1152}
1153
1154const EN: Words = Words {
1155    html_lang: "en",
1156    task: "Task",
1157    what_changed: "What changed",
1158    review_verdict: "Review verdict",
1159    reviewer: "Reviewer",
1160    reviewer_no_answer: "produced no answer",
1161    checks: "Checks",
1162    nothing_failing: "Nothing failing.",
1163    files_changed: "file(s) changed",
1164    commits: "Commits being squashed",
1165    no_commits: "No commit subjects could be read from the branch.",
1166    comments: "External PR review comments",
1167    no_comments: "No unaddressed external PR comments at this observation. This does not mean internal findings are resolved.",
1168    diff: "Diff",
1169    truncated: "Truncated",
1170    lands_as: "They land as one commit titled",
1171};
1172
1173const JA: Words = Words {
1174    html_lang: "ja",
1175    task: "タスク",
1176    what_changed: "変更内容",
1177    review_verdict: "レビューの結論",
1178    reviewer: "レビュアー",
1179    reviewer_no_answer: "回答なし",
1180    checks: "チェック",
1181    nothing_failing: "失敗しているものはありません。",
1182    files_changed: "ファイル変更",
1183    commits: "squash されるコミット",
1184    no_commits: "ブランチからコミット件名を読めませんでした。",
1185    comments: "外部 PR レビューコメント",
1186    no_comments: "この時点で未対応の外部 PR コメントはありません。内部指摘の解決を意味するものではありません。",
1187    diff: "差分",
1188    truncated: "省略",
1189    lands_as: "これらは次の件名の1コミットとして入ります:",
1190};
1191
1192impl Words {
1193    /// The clause after the merge subject. Split out because word order moves:
1194    /// Japanese puts the subject before the verb, so a shared template with a
1195    /// hole in the middle would read as machine translation.
1196    fn lands_as_tail(&self) -> &'static str {
1197        if self.html_lang == "ja" {
1198            "。この件名も承認の対象です。"
1199        } else {
1200            ", which you are approving too."
1201        }
1202    }
1203
1204    /// The question's own one-line summary, which is what a phone shows first.
1205    fn approval_summary(&self, number: u64, subject: &str) -> String {
1206        if self.html_lang == "ja" {
1207            format!("プルリクエスト #{number} をマージ: {subject}")
1208        } else {
1209            format!("merge pull request #{number}: {subject}")
1210        }
1211    }
1212
1213    /// The body under the summary, above the panel.
1214    fn approval_detail(
1215        &self,
1216        url: &str,
1217        base: &str,
1218        subject: &str,
1219        contested: Option<&ContestedHandoff>,
1220    ) -> String {
1221        let body = if self.html_lang == "ja" {
1222            format!(
1223                "{url} はチェックが緑で、`{base}` へ `{subject}` として squash \
1224                 できる状態です。差分の要約・パッチ・squash されるコミットは\
1225                 下のパネルにあります。"
1226            )
1227        } else {
1228            format!(
1229                "{url} is green and ready to squash into `{base}` as `{subject}`. \
1230                 The panel holds the diffstat, the patch and the commits being squashed."
1231            )
1232        };
1233        match contested {
1234            Some(c) => format!("{}\n\n{body}", self.contested_reason(url, c)),
1235            None => body,
1236        }
1237    }
1238
1239    /// Why this question exists although merge approvals are off: the open
1240    /// blocking findings and who rejected. Short enough for a phone.
1241    fn contested_reason(&self, url: &str, c: &ContestedHandoff) -> String {
1242        const SHOWN: usize = 5;
1243        const TITLE_CHARS: usize = 100;
1244        let ja = self.html_lang == "ja";
1245        let mut out = if ja {
1246            format!(
1247                "{url} は、マージ承認がオフでも保留しています。レビューが予算切れで終わった\
1248                 時点で、却下票を伴う重大な未解決の指摘が残っているためです。\n"
1249            )
1250        } else {
1251            format!(
1252                "{url} is held for approval although merge approvals are off: the \
1253                 review ended with blocking findings still open and a reviewer \
1254                 voting reject.\n"
1255            )
1256        };
1257        for f in c.findings.iter().take(SHOWN) {
1258            let at = match (&f.file, f.line) {
1259                (Some(file), Some(line)) => format!("{file}:{line}"),
1260                (Some(file), None) => file.clone(),
1261                _ => (if ja { "場所未指定" } else { "no location" }).to_owned(),
1262            };
1263            let title: String = f.title.chars().take(TITLE_CHARS).collect();
1264            let _ = writeln!(out, "- {} {} {at}: {title}", f.id, f.severity);
1265        }
1266        if c.findings.len() > SHOWN {
1267            let more = c.findings.len() - SHOWN;
1268            let _ = writeln!(
1269                out,
1270                "{}",
1271                if ja {
1272                    format!("- ほか {more} 件")
1273                } else {
1274                    format!("- and {more} more")
1275                }
1276            );
1277        }
1278        let seats: Vec<String> = c
1279            .rejecters
1280            .iter()
1281            .map(|(seat, agent)| format!("#{seat} ({agent})"))
1282            .collect();
1283        let _ = write!(
1284            out,
1285            "{} {}",
1286            if ja {
1287                "却下したレビュアー:"
1288            } else {
1289                "Rejected by reviewer:"
1290            },
1291            seats.join(", ")
1292        );
1293        out
1294    }
1295
1296    /// The truncation note, written whole in each language for the same reason.
1297    fn truncated_note(
1298        &self,
1299        omitted: usize,
1300        total: usize,
1301        shown: usize,
1302        where_: &str,
1303        base: &str,
1304        head: &str,
1305    ) -> String {
1306        if self.html_lang == "ja" {
1307            format!(
1308                "先頭 {shown} 行のあと、差分 {total} 行のうち {omitted} 行を省略しました。\
1309                 全体は <code>{where_}</code>(<code>git diff {base}...{head}</code>)と\
1310                 プルリクエストにあります。"
1311            )
1312        } else {
1313            format!(
1314                "{omitted} of {total} diff lines omitted after the first {shown}. \
1315                 The whole patch is in <code>{where_}</code> \
1316                 (<code>git diff {base}...{head}</code>) and on the pull request."
1317            )
1318        }
1319    }
1320}
1321
1322/// Pick the panel's language. Codes and names both, because `[graph] language`
1323/// has always accepted either.
1324fn words(language: &str) -> &'static Words {
1325    if crate::lang::is_japanese(language) {
1326        &JA
1327    } else {
1328        &EN
1329    }
1330}
1331
1332/// The approval panel's html: what is about to land, and the evidence for it.
1333///
1334/// Pure, so the whole document is asserted in tests without `gh`, without a
1335/// network and without a repository. The caller gathers `diffstat`
1336/// (`git diff --numstat`), `diff` (the unified patch), `commits` (the subjects
1337/// being squashed) and `subject` (what the squash will be called) from the
1338/// winner's worktree.
1339///
1340/// It emits no `<script>`, no `<form>` and no remote url, because the frame's
1341/// content security policy blocks all three: anything of the sort here would be
1342/// dead markup that misleads the next reader into thinking it works.
1343pub fn approval_panel(
1344    state: &RunState,
1345    pr: &PrState,
1346    diffstat: &str,
1347    diff: &str,
1348    commits: &[String],
1349    subject: &str,
1350) -> String {
1351    let rows = parse_numstat(diffstat);
1352    let w = words(&state.config.graph.language);
1353    let mut h = String::with_capacity(4_096 + diff.len().min(200_000));
1354
1355    let _ = writeln!(
1356        h,
1357        "<!doctype html>\n<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n\
1358         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
1359        w.html_lang
1360    );
1361    let _ = writeln!(
1362        h,
1363        "<title>merge #{} — {}</title>\n</head>",
1364        pr.number,
1365        esc(subject)
1366    );
1367    h.push_str(
1368        "<body style=\"margin:0;padding:12px;font:15px/1.5 -apple-system,\
1369         'Segoe UI',system-ui,sans-serif;color:#1f2328;background:#fff;\
1370         word-break:break-word\">\n",
1371    );
1372
1373    // The decision, in the words the operator is approving.
1374    let _ = writeln!(
1375        h,
1376        "<h1 style=\"margin:0 0 4px;font-size:19px\">Merge #{} into \
1377         <code style=\"background:#f6f8fa;padding:1px 4px;border-radius:4px\">{}</code></h1>\n\
1378         <p style=\"margin:0 0 4px;font-size:17px;font-weight:600\">{}</p>\n\
1379         <p style=\"margin:0 0 12px;font-size:13px;color:#57606a\">squash merge · run {} · \
1380         <a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:#0969da\">{}</a></p>",
1381        pr.number,
1382        esc(&state.base_branch),
1383        esc(subject),
1384        esc(&state.id),
1385        esc(&pr.url),
1386        esc(&pr.url),
1387    );
1388
1389    let _ = writeln!(
1390        h,
1391        "<section style=\"padding:12px;background:#fff8c5;border:1px solid #9a6700;border-radius:6px;white-space:pre-wrap\">{}</section>",
1392        esc(&approval_evidence(state))
1393    );
1394
1395    // The task, verbatim: the operator's own words for what was asked, so the
1396    // panel does not make them reconstruct the request from a diffstat.
1397    let _ = writeln!(
1398        h,
1399        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1400         <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1401        w.task,
1402        esc(&state.instruction)
1403    );
1404
1405    // The winner's own account of what it did and why, when there is one.
1406    if let Some(summary) = state
1407        .winner()
1408        .map(|c| c.summary.as_str())
1409        .filter(|s| !s.is_empty())
1410    {
1411        let _ = writeln!(
1412            h,
1413            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>\n\
1414             <p style=\"margin:0;font-size:13px;white-space:pre-wrap\">{}</p>",
1415            w.what_changed,
1416            esc(summary)
1417        );
1418    }
1419
1420    // Only the last round's reviewer prose is current. Its votes and
1421    // remaining findings are shown explicitly in the evidence above.
1422    if let Some(round) = state.reviews.last() {
1423        let _ = writeln!(
1424            h,
1425            "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1426            w.review_verdict
1427        );
1428        for r in &round.reviews {
1429            // A seat the review loop counted as answered has real prose in
1430            // `summary`; one it counted against `incomplete` (see
1431            // `graph::Runner::review_loop`) never produced any and left it
1432            // empty - which must not be read back as a blank verdict, since
1433            // an empty box here looks like "nothing to say" rather than
1434            // "never answered".
1435            let body = match &r.failed {
1436                Some(reason) => format!("{}: {}", w.reviewer_no_answer, esc(reason)),
1437                None => esc(&r.summary),
1438            };
1439            let _ = writeln!(
1440                h,
1441                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;\
1442                 border-radius:6px\">\
1443                 <div style=\"font-size:12px;color:#57606a\">{} {} · {}</div>\
1444                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1445                w.reviewer,
1446                r.reviewer,
1447                esc(&r.agent),
1448                body,
1449            );
1450        }
1451    }
1452
1453    let _ = writeln!(
1454        h,
1455        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}: {}</h2>",
1456        w.checks,
1457        esc(pr.checks.as_str())
1458    );
1459    if pr.failing.is_empty() {
1460        let _ = writeln!(
1461            h,
1462            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>",
1463            w.nothing_failing
1464        );
1465    } else {
1466        h.push_str("<ul style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1467        for f in &pr.failing {
1468            let _ = writeln!(h, "<li>{}</li>", esc(f));
1469        }
1470        h.push_str("</ul>\n");
1471    }
1472
1473    // Diffstat as a real table, so a phone reads what moved without scrolling
1474    // sideways through a terminal bar chart.
1475    let _ = writeln!(
1476        h,
1477        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{} {}</h2>",
1478        rows.len(),
1479        w.files_changed
1480    );
1481    h.push_str(
1482        "<table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n\
1483         <thead><tr>\
1484         <th style=\"text-align:left;border-bottom:1px solid #d0d7de;padding:4px 2px\">file</th>\
1485         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">added</th>\
1486         <th style=\"text-align:right;border-bottom:1px solid #d0d7de;padding:4px 2px\">removed\
1487         </th></tr></thead>\n<tbody>\n",
1488    );
1489    let mut total_added = 0u64;
1490    let mut total_removed = 0u64;
1491    for r in &rows {
1492        total_added += r.added.unwrap_or(0);
1493        total_removed += r.removed.unwrap_or(0);
1494        let cell = |n: Option<u64>| match n {
1495            Some(n) => n.to_string(),
1496            None => "bin".to_owned(),
1497        };
1498        let _ = writeln!(
1499            h,
1500            "<tr>\
1501             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;\
1502             font-family:ui-monospace,monospace\">{}</td>\
1503             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1504             color:#0a3622\">{}</td>\
1505             <td style=\"padding:4px 2px;border-bottom:1px solid #eaeef2;text-align:right;\
1506             color:#5c1a17\">{}</td></tr>",
1507            esc(&r.path),
1508            cell(r.added),
1509            cell(r.removed),
1510        );
1511    }
1512    let _ = writeln!(
1513        h,
1514        "</tbody>\n<tfoot><tr style=\"font-weight:600\">\
1515         <td style=\"padding:4px 2px\">total</td>\
1516         <td style=\"padding:4px 2px;text-align:right\">{total_added}</td>\
1517         <td style=\"padding:4px 2px;text-align:right\">{total_removed}</td>\
1518         </tr></tfoot>\n</table>"
1519    );
1520
1521    // The commits being squashed, and the subject that replaces them.
1522    let _ = writeln!(
1523        h,
1524        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1525        w.commits
1526    );
1527    if commits.is_empty() {
1528        h.push_str(&format!(
1529            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1530            w.no_commits
1531        ));
1532    } else {
1533        h.push_str("<ol style=\"margin:0;padding-left:20px;font-size:13px\">\n");
1534        for c in commits {
1535            let _ = writeln!(h, "<li>{}</li>", esc(c));
1536        }
1537        h.push_str("</ol>\n");
1538    }
1539    let _ = writeln!(
1540        h,
1541        "<p style=\"margin:8px 0 0;font-size:13px\">{} <strong>{}</strong>{}</p>",
1542        w.lands_as,
1543        esc(subject),
1544        w.lands_as_tail()
1545    );
1546
1547    // The review comments that shaped this branch, and who asked for them.
1548    let _ = writeln!(
1549        h,
1550        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1551        w.comments
1552    );
1553    if pr.review_comments.is_empty() {
1554        h.push_str(&format!(
1555            "<p style=\"margin:0;font-size:13px;color:#57606a\">{}</p>\n",
1556            w.no_comments
1557        ));
1558    } else {
1559        for c in &pr.review_comments {
1560            let anchor = match (&c.path, c.line) {
1561                (Some(p), Some(l)) => format!("{p}:{l}"),
1562                (Some(p), None) => p.clone(),
1563                _ => "pull request thread".to_owned(),
1564            };
1565            let _ = writeln!(
1566                h,
1567                "<div style=\"margin:0 0 8px;padding:8px;background:#f6f8fa;border-radius:6px\">\
1568                 <div style=\"font-size:12px;color:#57606a\">{} · {}</div>\
1569                 <div style=\"white-space:pre-wrap;font-size:13px\">{}</div></div>",
1570                esc(&c.author),
1571                esc(&anchor),
1572                esc(&tail(&c.body, 800)),
1573            );
1574        }
1575    }
1576
1577    // The patch itself.
1578    let total = diff.lines().count();
1579    let shown = total.min(DIFF_MAX_LINES);
1580    let _ = writeln!(
1581        h,
1582        "<h2 style=\"margin:16px 0 6px;font-size:15px\">{}</h2>",
1583        w.diff
1584    );
1585    h.push_str(
1586        "<div style=\"font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;\
1587         border:1px solid #d0d7de;border-radius:6px;overflow-x:auto\">\n",
1588    );
1589    for line in diff.lines().take(shown) {
1590        let (gutter, style, body) = diff_row(line);
1591        let _ = writeln!(
1592            h,
1593            "<div style=\"display:flex;{style}\">\
1594             <span style=\"flex:0 0 1.4em;text-align:center;user-select:none;\
1595             border-right:1px solid #d0d7de\">{gutter}</span>\
1596             <span style=\"white-space:pre;padding-left:6px\">{}</span></div>",
1597            esc(body),
1598        );
1599    }
1600    h.push_str("</div>\n");
1601    if total > shown {
1602        let omitted = total - shown;
1603        let head = state.winner().map_or("HEAD", |w| w.branch.as_str());
1604        let where_ = state.winner().map_or_else(
1605            || state.repo.display().to_string(),
1606            |w| w.worktree.display().to_string(),
1607        );
1608        let _ = writeln!(
1609            h,
1610            "<p style=\"margin:8px 0 0;padding:8px;background:#fff8c5;border-radius:6px;\
1611             font-size:13px\">{}: {}</p>",
1612            w.truncated,
1613            w.truncated_note(
1614                omitted,
1615                total,
1616                shown,
1617                &esc(&where_),
1618                &esc(&state.base_branch),
1619                &esc(head),
1620            ),
1621        );
1622    }
1623
1624    h.push_str("</body>\n</html>\n");
1625    h
1626}
1627
1628/// Historical contested hand-off evidence for the question detail. Approval
1629/// itself is decided by `approval_reasons`, including reject-only cases.
1630fn contested_to_ask(state: &RunState) -> Option<ContestedHandoff> {
1631    if state.owner_hold(crate::config::OwnerHold::HoldContestedMerge) {
1632        state.contested_handoff.clone()
1633    } else {
1634        None
1635    }
1636}
1637
1638/// What a merge-approval question's deputy is told: the pull request, the run,
1639/// what each answer does, and the evidence saved when the question was filed.
1640///
1641/// Rebuilt for every deputy turn, it is still a snapshot of that moment, so it
1642/// says so and points at `magi show` / `gh pr view` for anything current.
1643/// `state` is `None` for a run that cannot be read; what is missing is named as
1644/// missing, and no past approval basis is rebuilt from today's state.
1645pub fn deputy_brief(q: &ask::Question, state: Option<&RunState>) -> String {
1646    let mut s = format!(
1647        "This is the merge approval for run {run} (`magi show {run}`). The question's \
1648         own text above names the pull request. Answering `{APPROVE}` squash-merges \
1649         it into the base branch; `{HOLD}` leaves the pull request open. Silence is a \
1650         hold: the owner not answering never merges. When the owner tells you to \
1651         merge, in whatever words, settle `{APPROVE}`; when they ask for what another \
1652         offered choice does, settle that one. If you cannot tell what they want, ask \
1653         with `magi ask --thread`.\n\n\
1654         This brief is a snapshot from when your turn started: check `magi show {run}` \
1655         and `gh pr view` (read-only) before telling the owner anything current. The \
1656         driver carries out the merge, repair, withdrawal or replacement: do not \
1657         change files, branches or the pull request yourself.",
1658        run = q.run
1659    );
1660    if let Some(target) = &q.merge_target {
1661        s.push_str(&format!("\n\nThis decision names PR {} at head `{}`. A settled `{APPROVE}` merges exactly that head; a newer head is never merged on this answer. 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 for it yourself.", target.pr, target.head));
1662    }
1663    if q.choices.iter().any(|c| c == REPAIR) {
1664        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. The driver performs the repair; do not edit or queue another task for it. Updated review and gate results require a fresh merge decision.");
1665    }
1666    if q.detail.contains("Approval required because:") || q.detail.contains("承認が必要な理由:")
1667    {
1668        s.push_str("\n\nSaved approval evidence (at question creation):\n");
1669        s.push_str(&q.detail);
1670    } else {
1671        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.");
1672    }
1673    let Some(state) = state else {
1674        s.push_str(
1675            "\n\nThe run's record could not be read, so the pull request, the panel \
1676             summary and any contested findings are not known to you beyond the \
1677             question's own text. Say so to the owner rather than guessing.",
1678        );
1679        return s;
1680    };
1681    if let Some(pr) = &state.pr {
1682        s.push_str(&format!(
1683            "\n\nPull request #{} {} (recorded state: {}, last seen).",
1684            pr.number, pr.url, pr.state
1685        ));
1686    }
1687    s.push_str(&format!("\nBase branch: `{}`.", state.base_branch));
1688    if let Some(w) = state.winner() {
1689        s.push_str(&format!("\nWinning branch: `{}`.", w.branch));
1690    }
1691
1692    s
1693}
1694
1695// Releasing a task permits work to resume; it does not renew merge consent.
1696// Check the persisted operator decision both when reusing a card and at the
1697// final merge boundary, since a hold/release can arrive between those reads.
1698fn merge_consent_superseded(state: &RunState, question: &ask::Question) -> Result<bool> {
1699    if !question.status.open() && approval(question.resolution().as_deref()) != Approval::Merge {
1700        return Ok(false);
1701    }
1702    let Some(task_id) = state
1703        .origin
1704        .as_ref()
1705        .and_then(|origin| origin.task.as_ref())
1706    else {
1707        return Ok(false);
1708    };
1709    let task = crate::queue::Queue::open()
1710        .get(task_id)
1711        .context("cannot verify operator decisions for the merge approval")?;
1712    Ok(task
1713        .operator_decided_at
1714        .is_some_and(|at| at > question.answered_at.unwrap_or(question.asked_at)))
1715}
1716
1717/// Ask the owner before merging, with the whole case attached as a panel.
1718///
1719/// The evidence is gathered from the winner's own worktree with the `git` CLI,
1720/// never from the network, so a phone on a slow link gets the diff magi is
1721/// looking at rather than a link it has to go and open.
1722///
1723/// Never blocks. `land` used to sit inside [`ask::ask_and_wait`]'s poll loop
1724/// for up to a day right here, which held the whole run's task claim - and
1725/// the daemon's one slot with it - for exactly as long as the owner took to
1726/// notice their phone. [`ApprovalGate::Pending`] is the answer that lets the
1727/// caller park the run and hand the slot back instead: the question is on
1728/// disk either way, so nothing about the wait itself changes, only who is
1729/// blocked on it.
1730///
1731/// Idempotent across resumes: called again for a run already waiting on its
1732/// own question, this finds that question by [`crate::ask::Questions::list`]
1733/// rather than filing a second one - asking twice would double the
1734/// notification for one decision, and leave the first question's panel an
1735/// orphan nobody's answer ever reaches.
1736async fn approval_gate(
1737    state: &mut RunState,
1738    pr: &PrState,
1739    subject: &str,
1740    contested: Option<&ContestedHandoff>,
1741    head: &str,
1742) -> Result<ApprovalGate> {
1743    let store = ask::Questions::open();
1744    // An answer belongs to the commit its panel showed. A question recorded
1745    // for another head - or none recorded at all, as for a question filed
1746    // before heads were tracked - is never reused: a fix or rebase push made
1747    // a commit the owner has not seen, and their word does not carry over.
1748    let reusable = state.land_approval.as_ref().and_then(|a| {
1749        store
1750            .list()
1751            .into_iter()
1752            .find(|q| q.id == a.question)
1753            .filter(|q| {
1754                q.status != ask::QuestionStatus::Abandoned
1755                    && (a.head.eq_ignore_ascii_case(head)
1756                        || q.resolution()
1757                            .as_deref()
1758                            .is_some_and(crate::merge_decision::stops))
1759            })
1760    });
1761    let reusable = match reusable {
1762        Some(q) if merge_consent_superseded(state, &q)? => None,
1763        other => other,
1764    };
1765    if reusable.is_none() {
1766        for stale in store
1767            .list()
1768            .into_iter()
1769            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1770        {
1771            let why =
1772                "the pull request head or operator decision changed; asked again for approval";
1773            if let Err(e) = store.update(&stale.id, |q| {
1774                q.abandon(why);
1775                Ok(())
1776            }) {
1777                tracing::warn!("could not retire the superseded approval question: {e:#}");
1778            }
1779        }
1780    }
1781
1782    let q = match reusable {
1783        Some(mut q) => {
1784            if upgrade_repair_choice(&mut q, state) {
1785                q = store
1786                    .update(&q.id, |current| {
1787                        upgrade_repair_choice(current, state);
1788                        Ok(())
1789                    })?
1790                    .0;
1791            }
1792            q
1793        }
1794        None => {
1795            let worktree = match state.winner() {
1796                Some(w) => w.worktree.clone(),
1797                None => state.repo.clone(),
1798            };
1799            // The diff is built from the commit being approved, not from the
1800            // branch name, which may already point somewhere else.
1801            let head = if head.is_empty() {
1802                state
1803                    .winner()
1804                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1805            } else {
1806                head.to_owned()
1807            };
1808            // The diff is against what the remote's base holds, never the
1809            // local branch of that name; unreadable means less evidence.
1810            let remote = &state.config.merge.remote;
1811            let tracking = format!("{remote}/{}", state.base_branch);
1812            let base = if git::rev_exists(&worktree, &tracking).await {
1813                tracking
1814            } else {
1815                String::new()
1816            };
1817            let range = format!("{base}...{head}");
1818            // A failed `git` must not decide the merge: the panel degrades to
1819            // less evidence and the owner still chooses. Merging because the
1820            // diff could not be read would be the worst of both.
1821            let numstat = if base.is_empty() {
1822                String::new()
1823            } else {
1824                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1825                    .await
1826                    .map(|o| o.stdout)
1827                    .unwrap_or_default()
1828            };
1829            let diff = if base.is_empty() {
1830                String::new()
1831            } else {
1832                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1833            };
1834            let commits: Vec<String> = if base.is_empty() {
1835                Vec::new()
1836            } else {
1837                git::git_raw(
1838                    &worktree,
1839                    &[
1840                        "log",
1841                        "--reverse",
1842                        "--format=%s",
1843                        &format!("{base}..{head}"),
1844                    ],
1845                )
1846                .await
1847                .map(|o| o.stdout)
1848                .unwrap_or_default()
1849                .lines()
1850                .filter(|l| !l.trim().is_empty())
1851                .map(str::to_owned)
1852                .collect()
1853            };
1854
1855            let w = words(&state.config.graph.language);
1856            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1857            let mut fresh = ask::Question::new(
1858                state.id.clone(),
1859                APPROVAL_NODE.to_owned(),
1860                "land".to_owned(),
1861                w.approval_summary(pr.number, subject),
1862                format!(
1863                    "{}\n\n{}",
1864                    approval_evidence(state),
1865                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1866                ),
1867                approval_choices(state),
1868            );
1869            fresh.merge_target = Some(crate::merge_decision::Target {
1870                pr: pr.url.clone(),
1871                head: head.clone(),
1872                language: state.config.graph.language.clone(),
1873            });
1874            if fresh.choices.iter().any(|c| c == REPAIR) {
1875                fresh.actions.insert(
1876                    REPAIR.to_owned(),
1877                    ask::ChoiceAction::Resume {
1878                        run: state.id.clone(),
1879                    },
1880                );
1881                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.");
1882            }
1883            for choice in fresh.choices.clone() {
1884                if crate::merge_decision::stops(&choice)
1885                    && choice != HOLD
1886                    && choice != crate::merge_decision::DISMISS
1887                {
1888                    fresh.actions.insert(
1889                        choice,
1890                        ask::ChoiceAction::Resume {
1891                            run: state.id.clone(),
1892                        },
1893                    );
1894                }
1895            }
1896            store
1897                .put_panel(&mut fresh, &html, &[])
1898                .context("write the merge approval panel")?;
1899            store
1900                .put_executable(&mut fresh)
1901                .context("file the merge approval question")?;
1902            state.land_approval = Some(LandApproval {
1903                question: fresh.id.clone(),
1904                head: head.clone(),
1905            });
1906            state.event(
1907                "land",
1908                format!("asking for merge approval ({})", fresh.short()),
1909            );
1910            state.save()?;
1911            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1912                // A broken webhook is not a reason to lose the merge: the
1913                // question is already on disk and the web UI already shows
1914                // it, so the operator still has a way in.
1915                tracing::warn!(
1916                    "could not notify about merge approval question {}: {e:#} - \
1917                     the web UI is the only surface for it now",
1918                    fresh.short()
1919                );
1920            }
1921            fresh
1922        }
1923    };
1924
1925    Ok(match q.status {
1926        ask::QuestionStatus::Open => ApprovalGate::Pending,
1927        // Nobody answered before `state.config.graph.answer_timeout` passed,
1928        // or the question was closed with no decision recorded underneath
1929        // this run - either way there is nothing left to wait on.
1930        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1931        // The merge gate does not speak `--thread`: an owner who talked back
1932        // instead of choosing never reaches `Answered`, so this arm only
1933        // ever sees an actual decision.
1934        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1935            Approval::Merge => ApprovalGate::Approved,
1936            Approval::Repair if !state.reviews.is_empty() => {
1937                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1938                    state.merge_repairs.push(crate::run::MergeRepair {
1939                        question: q.id.clone(),
1940                        head: head.to_owned(),
1941                        pr: pr.url.clone(),
1942                        reviews: state.reviews.clone(),
1943                        gate: state.gate.clone(),
1944                        gate_head: state.gate_head.clone(),
1945                        gate_fixes: state.gate_fixes.clone(),
1946                        pre_gate: state.pre_gate.clone(),
1947                        phase: crate::run::MergeRepairPhase::Requested,
1948                        failure: None,
1949                    });
1950                    state.land_approval = None;
1951                    state.event(
1952                        "repair",
1953                        "owner requested fix findings and re-review; merge is not approved",
1954                    );
1955                    state.save()?;
1956                }
1957                ApprovalGate::Repair
1958            }
1959            Approval::Repair | Approval::Hold => ApprovalGate::Held,
1960        },
1961    })
1962}
1963
1964/// Fold a rollup's entries into one verdict plus the failing checks' labels.
1965/// No I/O. An empty rollup is `Unknown`, never green.
1966fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1967    let mut failing = Vec::new();
1968    let mut pending = false;
1969    let mut unknown = false;
1970    for check in rollup {
1971        match check.verdict() {
1972            Verdict::Pass => {}
1973            Verdict::Pending => pending = true,
1974            Verdict::Fail => failing.push(check.label()),
1975            Verdict::Unknown => unknown = true,
1976        }
1977    }
1978    let checks = if rollup.is_empty() {
1979        Checks::Unknown
1980    } else if pending {
1981        Checks::Pending
1982    } else if !failing.is_empty() {
1983        Checks::Red
1984    } else if unknown {
1985        Checks::Unknown
1986    } else {
1987        Checks::Green
1988    };
1989    (checks, failing)
1990}
1991
1992/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1993/// output into a [`PrState`]. No I/O.
1994pub fn parse_pr(json: &str) -> Result<PrState> {
1995    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
1996    let state = match raw.state.to_ascii_uppercase().as_str() {
1997        "OPEN" => PrLifecycle::Open,
1998        "MERGED" => PrLifecycle::Merged,
1999        "CLOSED" => PrLifecycle::Closed,
2000        other => bail!("unknown pull request state `{other}`"),
2001    };
2002
2003    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2004
2005    let mut review_comments = Vec::new();
2006    for r in raw.reviews {
2007        push_if_outstanding(
2008            &mut review_comments,
2009            ReviewComment {
2010                author: r.author.login,
2011                path: None,
2012                line: None,
2013                body: r.body,
2014            },
2015        );
2016    }
2017    for c in raw.comments {
2018        push_if_outstanding(
2019            &mut review_comments,
2020            ReviewComment {
2021                author: c.author.login,
2022                path: None,
2023                line: None,
2024                body: c.body,
2025            },
2026        );
2027    }
2028
2029    Ok(PrState {
2030        url: raw.url,
2031        number: raw.number,
2032        state,
2033        checks,
2034        failing,
2035        review_comments,
2036        blocking: Blocking::of(&raw.merge_state_status),
2037    })
2038}
2039
2040/// One rollup entry as the release watcher reads it.
2041#[derive(Debug, Clone, PartialEq, Eq)]
2042pub(crate) struct CheckView {
2043    pub name: String,
2044    pub verdict: Verdict,
2045    /// Workflow run behind the check, when its url names one.
2046    pub run: Option<String>,
2047    pub url: Option<String>,
2048}
2049
2050/// A pull request's lifecycle, head commit and per-check verdicts, without
2051/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2052/// from it, which is exactly what the release watcher must not inherit).
2053#[derive(Debug, Clone, PartialEq, Eq)]
2054pub(crate) struct RollupView {
2055    pub url: String,
2056    pub number: u64,
2057    pub state: PrLifecycle,
2058    pub head: String,
2059    pub checks: Vec<CheckView>,
2060}
2061
2062/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2063/// output. No I/O.
2064pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2065    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2066    let state = match raw.state.to_ascii_uppercase().as_str() {
2067        "OPEN" => PrLifecycle::Open,
2068        "MERGED" => PrLifecycle::Merged,
2069        "CLOSED" => PrLifecycle::Closed,
2070        other => bail!("unknown pull request state `{other}`"),
2071    };
2072    let checks = raw
2073        .status_check_rollup
2074        .iter()
2075        .map(|c| CheckView {
2076            name: c.label(),
2077            verdict: c.verdict(),
2078            run: c.url().and_then(run_of),
2079            url: c.url().map(str::to_owned),
2080        })
2081        .collect();
2082    Ok(RollupView {
2083        url: raw.url,
2084        number: raw.number,
2085        state,
2086        head: raw.head_ref_oid,
2087        checks,
2088    })
2089}
2090
2091/// Read just a pull request's lifecycle state - open, merged, or closed -
2092/// with none of the checks/reviews/comments [`land`] itself needs to decide
2093/// what to do next.
2094///
2095/// For a caller that only ever wants one fact and must not risk anything
2096/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2097/// it is actually a merged pull request *before* touching a run's state, so a
2098/// typo or a still-open PR fails loudly instead of quietly recording a merge
2099/// that never happened.
2100pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2101    let view = gh(
2102        repo,
2103        &[
2104            "pr".to_owned(),
2105            "view".to_owned(),
2106            pr_url.to_owned(),
2107            "--json".to_owned(),
2108            "state".to_owned(),
2109        ],
2110    )
2111    .await?;
2112    if !view.0 {
2113        bail!("gh pr view {pr_url}: {}", view.1);
2114    }
2115    // `parse_pr` reads every other field of `GhPr` as its serde default
2116    // (empty string, empty vec, zero) when this narrower `--json` selection
2117    // does not carry them - harmless, since only `.state` is read back.
2118    Ok(parse_pr(&view.1)?.state)
2119}
2120
2121/// A pull request the operator merged outside of `land::land`'s own loop,
2122/// found by asking GitHub about the run's own winning branch rather than
2123/// requiring the operator to go and find the URL themselves.
2124#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2125pub struct ExternalMerge {
2126    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2127    pub url: String,
2128    /// The pull request's number.
2129    pub number: u64,
2130}
2131
2132#[derive(Debug, Deserialize)]
2133#[serde(rename_all = "camelCase")]
2134struct GhMergedPr {
2135    url: String,
2136    number: u64,
2137    merged_at: String,
2138    base_ref_name: String,
2139}
2140
2141/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2142/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2143/// decide whether exactly one of the pull requests it lists could actually
2144/// be *this* run's.
2145///
2146/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2147/// from the run's own short id, so a collision with some other, unrelated
2148/// task's merged pull request from a same-named branch is rare but not
2149/// impossible once branches are deleted and ids run out. Filtering on
2150/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2151/// (which cannot predate the run itself) rules that case out. More than one
2152/// survivor is exactly as uninformative as zero — something this run cannot
2153/// tell apart from another — so only a unique survivor is returned.
2154fn pick_merged_pr(
2155    json: &str,
2156    base_branch: &str,
2157    created_at: Timestamp,
2158) -> Result<Option<ExternalMerge>> {
2159    let raw: Vec<GhMergedPr> =
2160        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2161    let mut matches: Vec<ExternalMerge> = Vec::new();
2162    for pr in raw {
2163        if pr.base_ref_name != base_branch {
2164            continue;
2165        }
2166        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2167            continue;
2168        };
2169        if merged_at < created_at {
2170            continue;
2171        }
2172        matches.push(ExternalMerge {
2173            url: pr.url,
2174            number: pr.number,
2175        });
2176    }
2177    if matches.len() == 1 {
2178        Ok(matches.pop())
2179    } else {
2180        Ok(None)
2181    }
2182}
2183
2184/// What `gh pr list --head <branch> --base <base> --state open` found.
2185#[derive(Debug, Clone, PartialEq, Eq)]
2186pub enum OpenPr {
2187    /// Nothing open: the caller creates one.
2188    None,
2189    /// Exactly one: the caller adopts it instead of creating a second.
2190    One {
2191        /// The pull request's URL.
2192        url: String,
2193        /// Its current title.
2194        title: String,
2195    },
2196    /// More than one: magi does not pick between them.
2197    Many(Vec<String>),
2198}
2199
2200#[derive(Debug, Deserialize)]
2201#[serde(rename_all = "camelCase")]
2202struct GhOpenPr {
2203    // `url` and `baseRefName` are required: a record missing either must be a
2204    // parse error, not a pull request that silently fails the base filter and
2205    // reads as "none open" (which would go on to create a duplicate).
2206    url: String,
2207    #[serde(default)]
2208    title: String,
2209    base_ref_name: String,
2210    // A fork's branch of the same name is not the task head; `gh pr list --head`
2211    // matches by branch name alone, so the filter has to be ours.
2212    #[serde(default)]
2213    is_cross_repository: bool,
2214}
2215
2216/// Pure half of [`find_open_pr`]: classify the raw `--json
2217/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2218/// dropped even though the query already filtered on it, so a stub or an old
2219/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2220/// adopted.
2221pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2222    let raw: Vec<GhOpenPr> =
2223        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2224    let mut hits: Vec<GhOpenPr> = raw
2225        .into_iter()
2226        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2227        .collect();
2228    Ok(match hits.len() {
2229        0 => OpenPr::None,
2230        1 => {
2231            let p = hits.remove(0);
2232            OpenPr::One {
2233                url: p.url,
2234                title: p.title,
2235            }
2236        }
2237        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2238    })
2239}
2240
2241/// Open pull requests whose head is `branch` and whose base is `base`. A
2242/// failing `gh` is an error carrying its own output, never "none": guessing
2243/// there is how a duplicate gets created.
2244pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2245    let (ok, out) = gh(
2246        repo,
2247        &[
2248            "pr".to_owned(),
2249            "list".to_owned(),
2250            "--head".to_owned(),
2251            branch.to_owned(),
2252            "--base".to_owned(),
2253            base.to_owned(),
2254            "--state".to_owned(),
2255            "open".to_owned(),
2256            "--json".to_owned(),
2257            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2258        ],
2259    )
2260    .await?;
2261    if !ok {
2262        bail!("gh pr list failed: {out}");
2263    }
2264    pick_open_pr(&out, base)
2265}
2266
2267#[derive(Debug, Deserialize)]
2268#[serde(rename_all = "camelCase")]
2269struct GhPrHead {
2270    head_ref_name: String,
2271    base_ref_name: String,
2272    state: String,
2273    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2274    // head lives in a fork must be a parse error, never "same repository".
2275    is_cross_repository: bool,
2276    // Required too: it is what ties the pull request to the commits that were
2277    // actually proven to be on the base.
2278    head_ref_oid: String,
2279}
2280
2281/// Why [`closable`] said no, and whether asking again later could say yes.
2282#[derive(Debug, Clone, PartialEq, Eq)]
2283pub struct Refusal {
2284    /// A later attempt may succeed (the head moved, the view was unreadable);
2285    /// `false` means this pull request is simply not the run's to close.
2286    pub retry: bool,
2287    /// What was wrong.
2288    pub why: String,
2289}
2290
2291impl Refusal {
2292    fn final_(why: String) -> Self {
2293        Self { retry: false, why }
2294    }
2295}
2296
2297/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2298/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2299/// is safe, `Err` carrying the reason when it is not.
2300///
2301/// Closing is outward-facing, so every property is checked on what the forge
2302/// says now, not on what magi recorded: the head must be exactly `branch` in
2303/// this repository (a fork's branch of the same name is somebody else's), the
2304/// base must be `base`, and the pull request must still be open.
2305pub fn closable(
2306    json: &str,
2307    branch: &str,
2308    base: &str,
2309    verified: &[String],
2310) -> std::result::Result<(), Refusal> {
2311    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2312        retry: true,
2313        why: format!("could not read the pull request ({e})"),
2314    })?;
2315    if pr.head_ref_name != branch {
2316        return Err(Refusal::final_(format!(
2317            "its head is `{}`, not this run's `{branch}`",
2318            pr.head_ref_name
2319        )));
2320    }
2321    if pr.is_cross_repository {
2322        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2323    }
2324    if pr.base_ref_name != base {
2325        return Err(Refusal::final_(format!(
2326            "it targets `{}`, not `{base}`",
2327            pr.base_ref_name
2328        )));
2329    }
2330    if !pr.state.eq_ignore_ascii_case("open") {
2331        return Err(Refusal::final_(format!(
2332            "it is already {}",
2333            pr.state.to_ascii_lowercase()
2334        )));
2335    }
2336    // Last, so a pull request that is not this run's at all is reported as
2337    // such. A head that is this branch but not a commit checked against the
2338    // base (somebody pushed since) may well get checked next time: retry.
2339    if !verified.contains(&pr.head_ref_oid) {
2340        return Err(Refusal {
2341            retry: true,
2342            why: format!(
2343                "its head {} is not a commit this run checked against the base",
2344                crate::already::short_sha(&pr.head_ref_oid)
2345            ),
2346        });
2347    }
2348    Ok(())
2349}
2350
2351/// Does `remote` point at something a forge could host - a URL or an scp-style
2352/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2353/// from the URL alone, so it answers the same on every machine, whatever `gh`
2354/// happens to be installed or logged in to.
2355async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2356    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2357        return false;
2358    };
2359    is_forge_url(url.trim())
2360}
2361
2362fn is_forge_url(url: &str) -> bool {
2363    url.contains("://") && !url.starts_with("file://")
2364        || url
2365            .split_once(':')
2366            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2367}
2368
2369/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2370/// request that failed?
2371fn forge_unavailable(message: &str) -> bool {
2372    message.contains("known GitHub host") || message.contains("spawn gh")
2373}
2374
2375/// The comment left on a pull request closed because its change is already on
2376/// the base.
2377pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2378    let how = match evidence.proof {
2379        crate::already::Proof::PatchId => format!(
2380            "carried by commit {} on `{base}` with the same patch",
2381            evidence.names()
2382        ),
2383        crate::already::Proof::Ancestry => {
2384            format!("already in the history of `{base}` as {}", evidence.names())
2385        }
2386        crate::already::Proof::Tree => format!(
2387            "already part of `{base}` (merging this branch changes nothing at {})",
2388            crate::already::short_sha(&evidence.tip)
2389        ),
2390    };
2391    format!(
2392        "Closing: everything this branch adds is {how}, so there is nothing left to \
2393         land. This pull request was closed automatically after that was verified; \
2394         reopen it if you disagree."
2395    )
2396}
2397
2398/// Close the open pull request for `branch`, if there is one and it is
2399/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2400/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2401/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2402/// failed `gh` call, a head that moved since it was checked), which the caller
2403/// must not treat as settled.
2404///
2405/// The recorded `state.pr` is preferred, else the forge is asked for an open
2406/// pull request on `branch`; either way the candidate is re-read and passed
2407/// through [`closable`] before anything is written; `verified` lists the
2408/// commits proven to be on the base, and the pull request's head must be one.
2409pub async fn close_superseded_pr(
2410    state: &mut RunState,
2411    branch: &str,
2412    evidence: &crate::already::Evidence,
2413    verified: &[String],
2414) -> Result<std::result::Result<String, String>> {
2415    let repo = state.repo.clone();
2416    let base = state.base_branch.clone();
2417    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2418        Some(p) => p.url.clone(),
2419        // No recorded pull request. A forge that cannot be asked at all (no
2420        // GitHub remote, no `gh`) says nothing about this run, so that is
2421        // "none found"; any other lookup failure is an error, because "could
2422        // not look" is not "nothing there" and the caller must retry.
2423        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2424            return Ok(Err(
2425                "the remote is not a forge, so there is no pull request".to_owned(),
2426            ));
2427        }
2428        None => match find_open_pr(&repo, branch, &base).await {
2429            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2430                return Ok(Err(format!("no forge to ask: {e:#}")));
2431            }
2432            Err(e) => return Err(e),
2433            Ok(OpenPr::One { url, .. }) => url,
2434            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2435            Ok(OpenPr::Many(urls)) => {
2436                return Ok(Err(format!(
2437                    "{} open pull requests name it; not choosing between them",
2438                    urls.len()
2439                )));
2440            }
2441        },
2442    };
2443    let (ok, view) = gh(
2444        &repo,
2445        &[
2446            "pr".to_owned(),
2447            "view".to_owned(),
2448            url.clone(),
2449            "--json".to_owned(),
2450            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2451        ],
2452    )
2453    .await?;
2454    if !ok {
2455        bail!("gh pr view {url} failed: {view}");
2456    }
2457    if let Err(refusal) = closable(&view, branch, &base, verified) {
2458        // A pull request whose head cannot be tied to what was proven is not
2459        // one to walk away from: the caller keeps the run resumable.
2460        if refusal.retry {
2461            bail!("left {url} open: {}", refusal.why);
2462        }
2463        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2464    }
2465    let (ok, out) = gh(
2466        &repo,
2467        &[
2468            "pr".to_owned(),
2469            "close".to_owned(),
2470            url.clone(),
2471            "--comment".to_owned(),
2472            superseded_comment(&base, evidence),
2473        ],
2474    )
2475    .await?;
2476    if !ok {
2477        bail!("gh pr close {url} failed: {out}");
2478    }
2479    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2480        p.state = "closed".to_owned();
2481    }
2482    Ok(Ok(url))
2483}
2484
2485/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2486/// differs from the one this run computed. Only the title: the body may have
2487/// been edited by the owner and cannot be compared.
2488pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2489    let (title, _) = crate::github_text::prepare(state, title, "");
2490    edit_pr_title(repo, url, &title).await
2491}
2492
2493/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2494/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2495/// gate is skipped on purpose.
2496pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2497    edit_pr_title(repo, url, title).await
2498}
2499
2500/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2501/// unmodified and without the posting gate.
2502pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2503    let (ok, _) = gh(
2504        repo,
2505        &[
2506            "pr".to_owned(),
2507            "edit".to_owned(),
2508            url.to_owned(),
2509            "--title".to_owned(),
2510            title.to_owned(),
2511            "--body".to_owned(),
2512            body.to_owned(),
2513        ],
2514    )
2515    .await
2516    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2517    if !ok {
2518        // Command output may echo the chosen text; keep it out of run events.
2519        bail!("gh pr edit failed while posting the chosen rewrite");
2520    }
2521    Ok(())
2522}
2523
2524async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2525    let (ok, out) = gh(
2526        repo,
2527        &[
2528            "pr".to_owned(),
2529            "edit".to_owned(),
2530            url.to_owned(),
2531            "--title".to_owned(),
2532            title.to_owned(),
2533        ],
2534    )
2535    .await?;
2536    if !ok {
2537        bail!("gh pr edit failed: {out}");
2538    }
2539    Ok(())
2540}
2541
2542/// Ask GitHub whether this run's winning candidate branch was actually merged
2543/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2544/// --merged` exists to close, minus the operator having to find the URL by
2545/// hand.
2546///
2547/// `Ok(None)` covers every case where nothing can be said with confidence: no
2548/// winner decided yet (nothing to check a branch for), no merged pull request
2549/// found, or [`pick_merged_pr`] found more than one candidate and would not
2550/// guess between them. Never wired to a weaker, URL-less signal like
2551/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2552/// separately, precisely because it cannot drive an automatic correction on
2553/// its own (see that function's own doc).
2554pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2555    let Some(winner) = state.winner() else {
2556        return Ok(None);
2557    };
2558    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2559    let out = gh(
2560        &state.repo,
2561        &[
2562            "pr".to_owned(),
2563            "list".to_owned(),
2564            "--head".to_owned(),
2565            branch.clone(),
2566            "--state".to_owned(),
2567            "merged".to_owned(),
2568            "--json".to_owned(),
2569            "url,number,mergedAt,baseRefName".to_owned(),
2570        ],
2571    )
2572    .await?;
2573    if !out.0 {
2574        bail!("gh pr list --head {branch}: {}", out.1);
2575    }
2576    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2577}
2578
2579/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2580/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2581///
2582/// Deliberately never consulted by [`find_external_merge`]: a base branch
2583/// that has moved since the run started can make an old, abandoned branch
2584/// look like an ancestor of the *current* base for reasons that have nothing
2585/// to do with a merge (a later commit that happens to supersede it, an
2586/// unrelated squash), and there is no pull request URL here to confirm
2587/// against or to land through anyway. Its only honest use is a weaker
2588/// notice — "this looks merged, go check" — never an automatic rewrite of
2589/// `status`/`merge`.
2590pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2591    let out = tokio::process::Command::new("git")
2592        .args(["merge-base", "--is-ancestor", branch, base_branch])
2593        .current_dir(repo)
2594        .quiet()
2595        .stdin(std::process::Stdio::null())
2596        .output()
2597        .await
2598        .context("spawn git merge-base --is-ancestor")?;
2599    Ok(out.status.success())
2600}
2601
2602/// Parse `host/owner/repo` out of a forge URL, with no network access.
2603///
2604/// The host is part of the slug, not discarded: `owner/repo` alone would
2605/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2606/// repository, which is exactly the mix-up the same-repo guard exists to
2607/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2608/// shape at all.
2609fn forge_slug(url: &str) -> Option<(String, &str)> {
2610    let rest = url.rsplit("://").next()?;
2611    let (host, path) = rest.split_once('/')?;
2612    if host.is_empty() {
2613        return None;
2614    }
2615    Some((host.to_ascii_lowercase(), path))
2616}
2617
2618/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2619/// access - the first half of the same-repo guard [`correct_manual_merge`]
2620/// applies before it writes anything.
2621///
2622/// Returns `None` for anything that does not look like
2623/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2624/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2625pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2626    let (host, path) = forge_slug(url)?;
2627    let mut segments = path.split('/');
2628    let owner = segments.next()?;
2629    let repo = segments.next()?;
2630    let kind = segments.next()?;
2631    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2632        return None;
2633    }
2634    Some(format!("{host}/{owner}/{repo}"))
2635}
2636
2637/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2638/// suffix), the shape `gh repo view --json url` returns - the other half of
2639/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2640fn slug_of_repo_url(url: &str) -> Option<String> {
2641    let (host, path) = forge_slug(url)?;
2642    let mut segments = path.split('/');
2643    let owner = segments.next()?;
2644    let repo = segments.next()?;
2645    if owner.is_empty() || repo.is_empty() {
2646        return None;
2647    }
2648    Some(format!("{host}/{owner}/{repo}"))
2649}
2650
2651/// Refuse to correct a run against a pull request from a different
2652/// repository than the one it is recorded against.
2653///
2654/// This is the guard the shun/8c75 incident argued for: an operator ran
2655/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2656/// in a different repository, omitted the run id, and the id defaulted to
2657/// this machine's most recently created run - an unrelated, still-in-progress
2658/// run in a completely different repository - which then had its `status`
2659/// rewritten to `merged` from a pull request it had nothing to do with.
2660/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2661/// CLI help), but a mistyped or stale id could still name a run in a
2662/// different repository than the one the URL belongs to, so this checks that
2663/// independently rather than trusting the id alone.
2664///
2665/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2666/// mismatch names both slugs rather than just refusing, so an operator whose
2667/// local checkout's `origin` is a fork of the repository the pull request was
2668/// opened against (a legitimate setup this cannot tell apart from a genuine
2669/// mix-up) can judge for themselves rather than being blocked with no way to
2670/// see why.
2671pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2672    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2673        return Ok(());
2674    }
2675    bail!(
2676        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2677         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2678         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2679         verify by hand before treating this as a false positive)"
2680    );
2681}
2682
2683/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2684/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2685///
2686/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2687/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2688/// repository the same way GitHub itself would recognize it, so the
2689/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2690/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2691/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2692/// a same-named one on a GitHub Enterprise host.
2693async fn repo_slug(repo: &Path) -> Result<String> {
2694    let out = gh(
2695        repo,
2696        &[
2697            "repo".to_owned(),
2698            "view".to_owned(),
2699            "--json".to_owned(),
2700            "url".to_owned(),
2701        ],
2702    )
2703    .await?;
2704    if !out.0 {
2705        bail!("gh repo view --json url: {}", out.1);
2706    }
2707    #[derive(Debug, Deserialize)]
2708    struct GhRepo {
2709        url: String,
2710    }
2711    let parsed: GhRepo = serde_json::from_str(&out.1)
2712        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2713    slug_of_repo_url(&parsed.url)
2714        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2715}
2716
2717/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2718/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2719/// would have written them had magi opened and merged this pull request
2720/// itself.
2721///
2722/// This is `magi fold --merged`'s whole implementation, and also what the
2723/// web `fold-merged` route calls once it has a URL in hand — an operator
2724/// recovery path for a merge magi could not finish on its own: a PR title too
2725/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2726/// closed by hand with a pull request magi never opened and so never
2727/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2728/// directly keeps this one authoritative: a merged pull request decides
2729/// `Step::Done { merged: true }` on the very first read, before any of
2730/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2731/// safe to call here even though this pull request was never magi's own.
2732///
2733/// [`ensure_same_repo`] is checked before anything else: a pull request from
2734/// a different repository than the one `state` is recorded against is
2735/// refused outright, regardless of its lifecycle. This is the guard for a
2736/// URL an *operator* hands in - the CLI or the web route - where a stale or
2737/// mistyped run id could otherwise get corrected from an unrelated
2738/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2739/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2740/// goes through [`correct_confirmed_external_merge`] instead, which skips
2741/// this check: its `url` was never operator-supplied, it comes from
2742/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2743/// it is already guaranteed to name a pull request in that same repository -
2744/// re-deriving and re-checking the repository here would only be a second
2745/// `gh repo view` call that can fail for reasons that have nothing to do with
2746/// correctness (a rate limit, a network blip), turning a self-heal that would
2747/// otherwise have succeeded into a run left `Blocked` for another pass.
2748///
2749/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2750/// URL fails loudly without writing anything, rather than handing an open
2751/// pull request to the full autonomous loop by accident.
2752///
2753/// Correcting `status` this way does not run `bump::after_merge`
2754/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2755/// which this path never goes through. A release version bump the change
2756/// might have earned is therefore not filed automatically and has to be
2757/// requested by hand - recorded as an event on the run so the gap is visible
2758/// to whoever reads it later, not just wherever this was called from. Follow-up
2759/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2760///
2761/// Returns the status before and after, so every caller (CLI, janitor, web
2762/// route) can build its own log line or response from the same pair rather
2763/// than each re-deriving it.
2764pub async fn correct_manual_merge(
2765    state: &mut RunState,
2766    url: &str,
2767) -> Result<(RunStatus, RunStatus)> {
2768    let Some(pr_slug) = slug_of_pr_url(url) else {
2769        bail!(
2770            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2771             this pull request belongs to"
2772        );
2773    };
2774    let run_slug = repo_slug(&state.repo).await?;
2775    ensure_same_repo(&run_slug, &pr_slug)?;
2776    correct_merge(state, url).await
2777}
2778
2779/// The janitor's own entry point into the same correction
2780/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2781/// same-repo guard - see that function's own doc for why skipping it here is
2782/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2783/// calls this with a `url` [`find_external_merge`] already found by querying
2784/// `state.repo`'s own remote, so the guard could never do anything here but
2785/// fail on its own transient errors.
2786///
2787/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2788/// because it lives in a different module.
2789pub(crate) async fn correct_confirmed_external_merge(
2790    state: &mut RunState,
2791    url: &str,
2792) -> Result<(RunStatus, RunStatus)> {
2793    correct_merge(state, url).await
2794}
2795
2796/// Same pull request, same repository: the url, or the number within one repo.
2797fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2798    let Some(pr) = a.pr.as_ref() else {
2799        return false;
2800    };
2801    if !url.is_empty()
2802        && pr
2803            .url
2804            .trim_end_matches('/')
2805            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2806    {
2807        return true;
2808    }
2809    number > 0
2810        && pr.number == number
2811        && match (a.repo.canonicalize(), repo.canonicalize()) {
2812            (Ok(x), Ok(y)) => x == y,
2813            _ => a.repo == repo,
2814        }
2815}
2816
2817/// Rewrite `pr.state` to a final state on every run record under `home` that
2818/// `decide` picks, and report how many were rewritten.
2819///
2820/// Lifecycle writes through to other runs are deliberately narrow (task-head
2821/// ownership is recorded separately): only a **terminal** run (never one a
2822/// driver may still be writing), only a proven-stopped driver with no daemon
2823/// claim (the shared liveness judge), only a record
2824/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2825/// The record is read as folding reads it (no schema check: every bump so far
2826/// only added fields, and the whole struct round-trips) and written through
2827/// [`RunState::save_under`], the path every record uses, so nothing but
2828/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2829/// A run that cannot be read or written is skipped with a warning.
2830fn rewrite_open_prs(
2831    home: &Path,
2832    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2833) -> usize {
2834    let now = Timestamp::now();
2835    let mut changed = 0;
2836    for id in crate::run::list_ids_in(&home.join("runs")) {
2837        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2838            continue;
2839        };
2840        let path = home.join("runs").join(&id).join("run.json");
2841        let Ok(body) = std::fs::read_to_string(&path) else {
2842            continue;
2843        };
2844        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2845            continue;
2846        };
2847        if !state.status.done()
2848            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2849            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2850                != crate::run::Liveness::Dead
2851        {
2852            continue;
2853        }
2854        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2855            continue;
2856        };
2857        if let Some(pr) = state.pr.as_mut() {
2858            pr.state = to.as_str().to_owned();
2859        }
2860        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2861        state.event(
2862            "land",
2863            format!("recorded {url} as {}: another run settled it", to.as_str()),
2864        );
2865        match state.save_under(home) {
2866            Ok(()) => changed += 1,
2867            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2868        }
2869    }
2870    changed
2871}
2872
2873/// A run reached a final state for its pull request: tell every other terminal
2874/// run in the same repository that names the same pull request (handed-over
2875/// predecessors, blocked attempts, anything), so their records stop saying
2876/// `open`. Best effort; `run`'s own record is the caller's.
2877pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2878    if to == PrLifecycle::Open {
2879        return;
2880    }
2881    let Some(home) = crate::run::try_home() else {
2882        return;
2883    };
2884    write_pr_state_through_in(&home, run, to);
2885}
2886
2887pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2888    let Some(pr) = run.pr.as_ref() else {
2889        return 0;
2890    };
2891    let (url, number) = (pr.url.clone(), pr.number);
2892    rewrite_open_prs(home, &mut |other| {
2893        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2894    })
2895}
2896
2897/// Terminal runs whose record still says their pull request is open, as
2898/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2899pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2900    let now = Timestamp::now();
2901    let mut out = Vec::new();
2902    for id in crate::run::list_ids_in(&home.join("runs")) {
2903        let path = home.join("runs").join(&id).join("run.json");
2904        let Ok(body) = std::fs::read_to_string(&path) else {
2905            continue;
2906        };
2907        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2908            continue;
2909        };
2910        if let Some(pr) = state.pr.as_ref()
2911            && state.status.done()
2912            && pr.state == "open"
2913            && !pr.url.is_empty()
2914            && !crate::daemon::is_working_on(home, &id, now)
2915        {
2916            out.push((id, state.repo.clone(), pr.url.clone()));
2917        }
2918    }
2919    out
2920}
2921
2922/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2923/// A url with no answer (the forge was unreadable) changes nothing.
2924pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2925    rewrite_open_prs(home, &mut |s| {
2926        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2927    })
2928}
2929
2930/// One-time (and idempotent) repair of records that froze an `open` pull
2931/// request: ask the forge about each distinct pull request that a terminal run
2932/// still calls open - at most `max_lookups` of them - and rewrite the merged
2933/// and closed ones. A genuinely open pull request is left alone, and a lookup
2934/// that fails is "unknown, change nothing". Returns `(records rewritten,
2935/// lookups that failed)`.
2936pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2937    let mut known = BTreeMap::new();
2938    let mut failed = 0;
2939    let mut seen = BTreeSet::new();
2940    for (_, repo, url) in stale_open_prs(home) {
2941        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2942            continue;
2943        }
2944        match lifecycle(&repo, &url).await {
2945            Ok(state) => {
2946                known.insert(url, state);
2947            }
2948            Err(e) => {
2949                tracing::warn!("repair pr state of {url}: {e:#}");
2950                failed += 1;
2951            }
2952        }
2953    }
2954    (apply_pr_states(home, &known), failed)
2955}
2956
2957async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2958    match lifecycle(&state.repo, url).await? {
2959        PrLifecycle::Merged => {}
2960        other => bail!(
2961            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2962            other.as_str(),
2963            state.id
2964        ),
2965    }
2966    let before = state.status;
2967    if let Err(e) = land(state, url).await {
2968        // `land` sets `status` to `Landing` and saves before its first read
2969        // of the pull request — see its own doc — so a failure here (a
2970        // transient `gh` hiccup between the two forge reads this function
2971        // makes) can leave the run stuck on that in-between value with
2972        // nothing left driving it. Land it on the same terminal shape an
2973        // automated `land` failure lands on instead of leaving it stuck.
2974        state.status = RunStatus::Blocked;
2975        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2976        state.save()?;
2977        return Err(e).context(format!("confirming the merge of {url}"));
2978    }
2979    state.event(
2980        "fold",
2981        "operator recorded this pull request as a manual merge; this run never \
2982         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2983         bump this change might warrant has to be filed by hand",
2984    );
2985    // Unlike the bump, the findings the merge left open are filed on this
2986    // path too: nothing else would ever carry them forward.
2987    if state.status == RunStatus::Merged {
2988        crate::followup::after_merge(state, url).await;
2989    }
2990    state.save()?;
2991    Ok((before, state.status))
2992}
2993
2994/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
2995/// comments. No I/O.
2996///
2997/// `gh pr view` does not surface inline comments, and inline is exactly where
2998/// both review bots put their findings - a landing loop that read only the
2999/// top-level thread would never see the thing it is supposed to fix.
3000pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3001    let raw: Vec<GhInline> =
3002        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3003    let mut out = Vec::new();
3004    for c in raw {
3005        push_if_outstanding(
3006            &mut out,
3007            ReviewComment {
3008                author: c.user.login,
3009                path: c.path,
3010                line: c.line,
3011                body: c.body,
3012            },
3013        );
3014    }
3015    Ok(out)
3016}
3017
3018/// Keep a comment only when it asks for something.
3019///
3020/// An inline comment always does: it names a file and a line. A top-level
3021/// comment is dropped when it is empty, when it is magi's own, or when it is
3022/// [noise](is_noise).
3023fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3024    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3025        return;
3026    }
3027    if comment.path.is_none() && is_noise(&comment.body) {
3028        return;
3029    }
3030    out.push(comment);
3031}
3032
3033/// Is this comment body machinery rather than a finding?
3034///
3035/// Two tests, both structural, because guessing from prose is how a "looks
3036/// good to me" turns into a fix round:
3037///
3038/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3039///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3040///    footer.
3041/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3042///    horizontal rules, and the bot's own status banner are removed, every
3043///    remaining line is a task-list item. That is exactly the shape of the
3044///    comment the Claude review job posts while it is still working.
3045///
3046/// Anything else is input, including bot prose. A bot that writes a paragraph
3047/// has said something, and the fix prompt tells the fixer it may decline a
3048/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3049/// missed finding.
3050pub fn is_noise(body: &str) -> bool {
3051    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3052        return true;
3053    }
3054    let mut content = false;
3055    for line in strip_blocks(body).lines() {
3056        let line = unquote(line);
3057        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3058            continue;
3059        }
3060        content = true;
3061        break;
3062    }
3063    !content
3064}
3065
3066/// Remove HTML comments and collapsed `<details>` blocks.
3067fn strip_blocks(body: &str) -> String {
3068    let mut out = String::with_capacity(body.len());
3069    let mut rest = body;
3070    loop {
3071        let open = ["<!--", "<details>"]
3072            .iter()
3073            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3074            .min_by_key(|(i, _)| *i);
3075        let Some((at, tag)) = open else {
3076            out.push_str(rest);
3077            return out;
3078        };
3079        out.push_str(&rest[..at]);
3080        let after = &rest[at + tag.len()..];
3081        let close = if tag == "<!--" { "-->" } else { "</details>" };
3082        match after.find(close) {
3083            Some(end) => rest = &after[end + close.len()..],
3084            // Unterminated: the rest of the body is inside the block.
3085            None => return out,
3086        }
3087    }
3088}
3089
3090/// Strip blockquote markers, which both bots wrap their callouts in.
3091fn unquote(line: &str) -> &str {
3092    let mut s = line.trim();
3093    while let Some(rest) = s.strip_prefix('>') {
3094        s = rest.trim_start();
3095    }
3096    s.trim()
3097}
3098
3099/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3100fn is_checklist(line: &str) -> bool {
3101    let rest = line
3102        .strip_prefix("- ")
3103        .or_else(|| line.strip_prefix("* "))
3104        .unwrap_or("");
3105    let rest = rest.trim_start();
3106    matches!(
3107        rest.get(..3),
3108        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3109    )
3110}
3111
3112/// A heading, a horizontal rule, or a callout tag - shape, never content.
3113fn is_decoration(line: &str) -> bool {
3114    line.starts_with('#')
3115        || line.starts_with("[!")
3116        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3117}
3118
3119/// A line that is nothing but emphasis and links.
3120///
3121/// Both review jobs open with a status banner
3122/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3123/// to a line-based test and asks for nothing, so it is measured the same way a
3124/// heading is: strip the markup, and if no word survives, it was decoration.
3125fn is_banner(line: &str) -> bool {
3126    let plain = drop_spans(line, "**", "**");
3127    let plain = if plain.contains("](") {
3128        drop_spans(&plain, "[", ")")
3129    } else {
3130        plain
3131    };
3132    !plain.chars().any(char::is_alphanumeric)
3133}
3134
3135/// Remove every `open` .. `close` span, including the delimiters. An
3136/// unterminated span swallows the rest of the input, which is what a reader
3137/// sees too.
3138fn drop_spans(s: &str, open: &str, close: &str) -> String {
3139    let mut out = String::with_capacity(s.len());
3140    let mut rest = s;
3141    while let Some(at) = rest.find(open) {
3142        out.push_str(&rest[..at]);
3143        let after = &rest[at + open.len()..];
3144        match after.find(close) {
3145            Some(end) => rest = &after[end + close.len()..],
3146            None => return out,
3147        }
3148    }
3149    out.push_str(rest);
3150    out
3151}
3152
3153/// The lock that keeps at most one run per repository actually moving the
3154/// base branch at a time: a rebase push, or `gh pr merge`.
3155///
3156/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3157/// running a fix round in the winner's own worktree, waiting on the owner's
3158/// approval - touches nothing a *different* run in the same repository could
3159/// collide with, and holding a lock across any of that would serialise one
3160/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3161/// resume, which is precisely the "must not wait on another task" property
3162/// the daemon's slot-freeing exists to give a resume. Only the two moments
3163/// that actually write to the shared base branch need mutual exclusion, and
3164/// both are brief.
3165///
3166/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3167/// different repositories' runs never wait on each other. The outer
3168/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3169/// insert an entry and clone its `Arc`, never across an `.await`.
3170fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3171    static LOCKS: std::sync::LazyLock<
3172        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3173    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3174    LOCKS
3175        .lock()
3176        .unwrap_or_else(std::sync::PoisonError::into_inner)
3177        .entry(repo.to_path_buf())
3178        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3179        .clone()
3180}
3181
3182/// `owner/repo` out of a pull request url, falling back to the checkout's
3183/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3184fn repo_label(repo: &Path, pr_url: &str) -> String {
3185    let parts: Vec<&str> = pr_url.split('/').collect();
3186    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3187        && at >= 2
3188        && !parts[at - 1].is_empty()
3189        && !parts[at - 2].is_empty()
3190    {
3191        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3192    }
3193    repo.file_name()
3194        .map(|n| n.to_string_lossy().into_owned())
3195        .unwrap_or_default()
3196}
3197
3198/// The operator-facing sentence for a merge that went ahead with red checks,
3199/// or `None` when the checks were not red. Judged on `checks`, not on
3200/// `failing`, which can be non-empty on a green observation.
3201fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3202    (pr.checks == Checks::Red).then(|| {
3203        let details = crate::notices::format_list(&pr.failing)
3204            .map(|names| format!(": {names}"))
3205            .unwrap_or_default();
3206        format!(
3207            "Merged {repo_name} PR #{} with red checks{details} ({})",
3208            pr.number, pr.url
3209        )
3210    })
3211}
3212
3213/// After a merge that succeeded: record which checks were red and tell the
3214/// operator. The decision to merge is already made; this only makes it audible.
3215/// Best-effort - a broken notifier never fails the run.
3216async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3217    let repo_name = repo_label(&state.repo, &pr.url);
3218    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3219        return;
3220    };
3221    if let Some(rec) = state.pr.as_mut() {
3222        rec.red_at_merge = pr.failing.clone();
3223    }
3224    state.event("land", summary.clone());
3225    // The notice pages through `[notify]` itself, once, unless a question
3226    // already carries the cause.
3227    crate::notices::raise_with(
3228        crate::notices::merged_red(&state.id, &summary),
3229        &state.config.notify,
3230    );
3231}
3232
3233/// Run the loop against a real pull request until it merges or the budget runs
3234/// out.
3235///
3236/// The caller decides whether landing happens at all: this is only reached when
3237/// `graph.land` is on. Returns the last observation, so the caller can report
3238/// what magi was looking at when it stopped.
3239pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3240    land_with(state, pr_url, &GhForge).await
3241}
3242
3243/// The forge calls whose timing the loop's decisions depend on, behind a seam
3244/// so a test can script what the pull request looks like from one observation
3245/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3246trait Forge {
3247    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3248        let (ok, out) = gh(
3249            repo,
3250            &[
3251                "pr".into(),
3252                "close".into(),
3253                pr_url.into(),
3254                "--comment".into(),
3255                comment.into(),
3256            ],
3257        )
3258        .await?;
3259        if !ok {
3260            bail!("PR closure failed: {out}");
3261        }
3262        Ok(())
3263    }
3264    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3265        None
3266    }
3267    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3268    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3269    async fn poll(&self);
3270    /// Rerun the failed jobs of one workflow run.
3271    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3272    /// Unix seconds, behind the seam so a test owns the clock.
3273    fn now(&self) -> i64;
3274    /// The check contexts the base branch requires, for a stop reason only.
3275    /// `None` when they could not be read, which is not the same as none.
3276    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3277    #[allow(clippy::too_many_arguments)]
3278    async fn fix(
3279        &self,
3280        state: &mut RunState,
3281        pr: &PrState,
3282        round: usize,
3283        budget: usize,
3284        reason: &str,
3285        logs: &str,
3286    ) -> Result<Fixed>;
3287}
3288
3289struct GhForge;
3290
3291impl Forge for GhForge {
3292    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3293        collect_ci_evidence(state, seen).await
3294    }
3295    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3296        observe(repo, pr_url).await
3297    }
3298    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3299        gh(repo, argv).await
3300    }
3301    async fn poll(&self) {
3302        tokio::time::sleep(POLL).await;
3303    }
3304    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3305        let args = [
3306            "run".to_owned(),
3307            "rerun".to_owned(),
3308            run.to_owned(),
3309            "--failed".to_owned(),
3310        ];
3311        let (ok, out) = gh(repo, &args).await?;
3312        if !ok {
3313            bail!("gh run rerun {run}: {out}");
3314        }
3315        Ok(())
3316    }
3317    fn now(&self) -> i64 {
3318        jiff::Timestamp::now().as_second()
3319    }
3320    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3321        required_contexts_of(repo, base).await
3322    }
3323    async fn fix(
3324        &self,
3325        state: &mut RunState,
3326        pr: &PrState,
3327        round: usize,
3328        budget: usize,
3329        reason: &str,
3330        logs: &str,
3331    ) -> Result<Fixed> {
3332        fix_round(state, pr, round, budget, reason, logs).await
3333    }
3334}
3335
3336/// Percent-encode a branch name for a URL path segment (`/` included).
3337fn encode_path_segment(s: &str) -> String {
3338    let mut out = String::new();
3339    for b in s.bytes() {
3340        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3341            out.push(b as char);
3342        } else {
3343            let _ = write!(out, "%{b:02X}");
3344        }
3345    }
3346    out
3347}
3348
3349/// Read the required contexts of `base` from classic branch protection and
3350/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3351/// these two endpoints do not list are missed. Any unreadable source makes the
3352/// whole answer `None`: a partial set would read as a complete one.
3353async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3354    let enc = encode_path_segment(base);
3355    let mut all = BTreeSet::new();
3356    // Classic protection answers 404 for an unprotected branch, which is
3357    // "nothing required here", not a failure to read.
3358    let classic = gh(
3359        repo,
3360        &[
3361            "api".to_owned(),
3362            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3363        ],
3364    )
3365    .await
3366    .ok()?;
3367    if classic.0 {
3368        all.extend(parse_classic_required(&classic.1)?);
3369    } else if !classic.1.contains("404") {
3370        return None;
3371    }
3372    let rules = gh(
3373        repo,
3374        &[
3375            "api".to_owned(),
3376            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3377        ],
3378    )
3379    .await
3380    .ok()?;
3381    if !rules.0 {
3382        return None;
3383    }
3384    all.extend(parse_ruleset_required(&rules.1)?);
3385    Some(all)
3386}
3387
3388/// `required_status_checks` of classic protection: `contexts` plus the
3389/// `checks[].context` form.
3390fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3391    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3392    let mut out = BTreeSet::new();
3393    for c in v.get("contexts")?.as_array()? {
3394        out.insert(c.as_str()?.to_owned());
3395    }
3396    for c in v
3397        .get("checks")
3398        .and_then(|c| c.as_array())
3399        .into_iter()
3400        .flatten()
3401    {
3402        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3403            out.insert(name.to_owned());
3404        }
3405    }
3406    Some(out)
3407}
3408
3409/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3410fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3411    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3412    let mut out = BTreeSet::new();
3413    for rule in v.as_array()? {
3414        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3415            continue;
3416        }
3417        let checks = rule
3418            .pointer("/parameters/required_status_checks")?
3419            .as_array()?;
3420        for c in checks {
3421            out.insert(c.get("context")?.as_str()?.to_owned());
3422        }
3423    }
3424    Some(out)
3425}
3426
3427/// Is the observation older than the commit a fix round pushed?
3428///
3429/// The forge takes a few seconds to move the pull request to a new head and
3430/// attach that head's check runs, and until it has, the rollup is the previous
3431/// head's - all green, which is exactly what a merge decision must not read.
3432/// An absent or different head counts as not yet: guessing "close enough"
3433/// would reopen the hole.
3434fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3435    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3436}
3437
3438/// The commit an observation may be decided on, or `None` while it cannot be
3439/// trusted to describe one.
3440///
3441/// `None` when a pushed commit is awaited and the pull request is not on it,
3442/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3443/// last commit's) is not the head's: that is the previous commit's checks. The
3444/// caller re-polls on `None`. A rollup that cannot be read (including one
3445/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3446/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3447fn bound_head<'a>(
3448    seen_head: &'a str,
3449    rollup_head: &str,
3450    awaiting: Option<&str>,
3451) -> Option<&'a str> {
3452    if seen_head.is_empty()
3453        || awaiting_new_head(awaiting, seen_head)
3454        || !rollup_head.eq_ignore_ascii_case(seen_head)
3455    {
3456        return None;
3457    }
3458    Some(seen_head)
3459}
3460
3461/// What a refused `gh pr merge` means.
3462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3463enum Refused {
3464    /// The branch policy is not satisfied *yet*: go back to waiting.
3465    Pending,
3466    /// Checks have settled and the merge state still says no, seen for the
3467    /// first time. The forge updates `mergeStateStatus` a moment after the last
3468    /// check finishes, so one more look is allowed before believing it.
3469    Recheck,
3470    /// Nothing is in flight and the policy still refuses: a person has to
3471    /// supply what it asks for (a review, say).
3472    Final,
3473}
3474
3475/// Judged from the pull request's state after the refusal, never from the
3476/// refusal's wording, which belongs to the forge and changes.
3477fn classify_refusal(
3478    after: Option<&Seen>,
3479    rechecked: bool,
3480    observed_head: &str,
3481    ci: CiExpectation,
3482) -> Refused {
3483    let Some(after) = after else {
3484        // Unreadable is not evidence of anything; the next loop reads again.
3485        return Refused::Pending;
3486    };
3487    if after.pr.state != PrLifecycle::Open {
3488        return Refused::Final;
3489    }
3490    // The branch moved after it was observed (the forge refuses a merge pinned
3491    // to the old commit): not a verdict on anything, look again.
3492    if !after.head.eq_ignore_ascii_case(observed_head) {
3493        return Refused::Pending;
3494    }
3495    // The same binding the loop applies: checks of another commit say nothing.
3496    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3497        return Refused::Pending;
3498    }
3499    let state = after.merge_state.to_ascii_uppercase();
3500    if (ci == CiExpectation::Expected
3501        && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3502        || state.is_empty()
3503        || state == "UNKNOWN"
3504    {
3505        return Refused::Pending;
3506    }
3507    if rechecked {
3508        Refused::Final
3509    } else {
3510        Refused::Recheck
3511    }
3512}
3513
3514/// Take auto-merge back from the forge and forget that it was armed.
3515///
3516/// `Err` carries the forge's message: the caller must not push or move on, as
3517/// an armed merge left behind could still fire for a commit nobody approved.
3518async fn disarm<F: Forge>(
3519    forge: &F,
3520    state: &mut RunState,
3521    repo: &Path,
3522    number: u64,
3523) -> std::result::Result<(), String> {
3524    let argv = disable_automerge_argv(number);
3525    let out = {
3526        let merge_lock = repo_merge_lock(repo);
3527        let _merge_slot = merge_lock.lock().await;
3528        forge.merge(repo, &argv).await
3529    };
3530    match out {
3531        Ok((true, _)) => {
3532            state.land_armed_head = None;
3533            state.event("land", "auto-merge disabled");
3534            state.save().map_err(|e| format!("{e:#}"))?;
3535            Ok(())
3536        }
3537        Ok((false, msg)) => Err(msg),
3538        Err(e) => Err(format!("{e:#}")),
3539    }
3540}
3541
3542/// [`stop`], first taking back an armed auto-merge so a pull request magi
3543/// gave up on does not merge on its own later. A failure to disarm is added
3544/// to the reason rather than hiding it.
3545async fn stop_disarmed<F: Forge>(
3546    forge: &F,
3547    state: &mut RunState,
3548    repo: &Path,
3549    pr: &PrState,
3550    why: &str,
3551) -> Result<()> {
3552    if state.land_armed_head.is_none() {
3553        return stop(state, repo, pr, why).await;
3554    }
3555    match disarm(forge, state, repo, pr.number).await {
3556        Ok(()) => stop(state, repo, pr, why).await,
3557        Err(e) => {
3558            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3559            stop(state, repo, pr, &why).await
3560        }
3561    }
3562}
3563
3564/// Check the persisted card again around awaits that can outlive an owner tap.
3565fn owner_requested_stop(state: &RunState) -> Result<bool> {
3566    let Some(binding) = &state.land_approval else {
3567        return Ok(false);
3568    };
3569    let q = ask::Questions::open().get(&binding.question)?;
3570    Ok(q.run == state.id
3571        && q.node == APPROVAL_NODE
3572        && q.resolution()
3573            .as_deref()
3574            .is_some_and(crate::merge_decision::stops))
3575}
3576
3577/// Read stop choices before checks, repair or rebase. These are about stopping
3578/// ownership, so a changed head never cancels them as it cancels merge consent.
3579async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3580    use crate::merge_decision as md;
3581    let Some(binding) = &state.land_approval else {
3582        return Ok(false);
3583    };
3584    let q = ask::Questions::open().get(&binding.question)?;
3585    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3586        return Ok(false);
3587    };
3588    if q.run != state.id || q.node != APPROVAL_NODE {
3589        bail!("stop decision names another run");
3590    }
3591    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3592        pr: pr.url.clone(),
3593        head: binding.head.clone(),
3594        language: state.config.graph.language.clone(),
3595    });
3596    if target.pr != pr.url {
3597        bail!("stop decision names another PR");
3598    }
3599    if state.merge_decision.is_none() {
3600        state.merge_decision = Some(md::Decision {
3601            question: q.id.clone(),
3602            target,
3603            choice: choice.clone(),
3604            closed: false,
3605            replacement_run: None,
3606            replacement_pr: None,
3607        });
3608        state.save()?;
3609    }
3610    let repo = state.repo.clone();
3611    // Always disable, including arms left by an older driver.
3612    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3613        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3614        return Ok(true);
3615    }
3616    if choice.eq_ignore_ascii_case(HOLD) {
3617        stop(
3618            state,
3619            &repo,
3620            pr,
3621            "owner held the decision: PR left open; no further repair or merge work",
3622        )
3623        .await?;
3624        return Ok(true);
3625    }
3626    if md::selected_agent(&choice).is_some()
3627        || state
3628            .origin
3629            .as_ref()
3630            .and_then(|o| o.task.as_ref())
3631            .is_some()
3632    {
3633        let owners: Vec<_> = crate::queue::Queue::open()
3634            .list()
3635            .into_iter()
3636            .filter(|task| task.runs.last() == Some(&state.id))
3637            .collect();
3638        let owner = (owners.len() == 1).then(|| &owners[0]);
3639        if !owner.is_some_and(|task| {
3640            task.runs.last() == Some(&state.id)
3641                && (!task
3642                    .operator_decided_at
3643                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3644                    || task.resumption.as_ref().is_some_and(|release| {
3645                        release.question_id.as_deref() == Some(q.id.as_str())
3646                            && task.operator_decided_at == Some(release.at)
3647                    }))
3648        }) {
3649            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3650            return Ok(true);
3651        }
3652    }
3653    if pr.state == PrLifecycle::Merged {
3654        stop(
3655            state,
3656            &repo,
3657            pr,
3658            "PR already merged; withdrawal/replacement cannot be applied",
3659        )
3660        .await?;
3661        return Ok(true);
3662    }
3663    let comment = format!(
3664        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3665        q.id, choice, state.id
3666    );
3667    if pr.state == PrLifecycle::Open {
3668        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3669            stop(
3670                state,
3671                &repo,
3672                pr,
3673                &format!(
3674                    "owner requested closure but it failed: {e}; task held, no replacement started"
3675                ),
3676            )
3677            .await?;
3678            return Ok(true);
3679        }
3680    }
3681    let confirmed = forge.view(&repo, &pr.url).await?;
3682    if confirmed.pr.state != PrLifecycle::Closed {
3683        stop(
3684            state,
3685            &repo,
3686            pr,
3687            "PR closure was not confirmed; task held, no replacement started",
3688        )
3689        .await?;
3690        return Ok(true);
3691    }
3692    *pr = confirmed.pr;
3693    state.merge_decision.as_mut().unwrap().closed = true;
3694    if let Some(record) = &mut state.pr {
3695        record.state = "closed".into();
3696    }
3697    state.status = RunStatus::Superseded;
3698    state.merge = Some(MergeOutcome {
3699        mode: MergeMode::Pr,
3700        ok: false,
3701        detail: format!("owner decision: {choice}; PR closure confirmed"),
3702        empty: false,
3703    });
3704    state.event(
3705        "land",
3706        format!(
3707            "{} applied: PR closure confirmed; old execution retired",
3708            choice
3709        ),
3710    );
3711    state.save()?;
3712    Ok(true)
3713}
3714
3715/// Keep an unstarted verification resumable without charging a task attempt.
3716fn check_landing_resources(
3717    state: &mut RunState,
3718    node: &str,
3719    outcomes: &[crate::run::CommandOutcome],
3720) -> Result<()> {
3721    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3722        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3723        state
3724            .resource_block
3725            .get_or_insert_with(|| crate::run::ResourceBlock {
3726                cause: crate::run::ResourceBlockCause::Other,
3727                node: node.into(),
3728                kind: "shared build cache".into(),
3729                detail: detail.clone(),
3730                at: jiff::Timestamp::now(),
3731            });
3732        state.event("resource", &detail);
3733        state.save()?;
3734        bail!("landing verification could not start: {detail}");
3735    }
3736    Ok(())
3737}
3738
3739/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3740/// A push completed before a crash is recoverable only when its SHA was gated.
3741async fn publish_landing_head<F: Forge>(
3742    state: &mut RunState,
3743    observed: &str,
3744    pr_url: &str,
3745    forge: &F,
3746) -> Result<String> {
3747    crate::pr_head::ensure_owner(state)?;
3748    let winner = state.winner().cloned().context("landing has no winner")?;
3749    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3750    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3751    anyhow::ensure!(
3752        observed == expected
3753            || (observed == head
3754                && state.gate_head.as_deref() == Some(&head)
3755                && state.gate_allows_progress()),
3756        "public head moved outside this run; refusing to overwrite it"
3757    );
3758    anyhow::ensure!(
3759        git::is_clean(&winner.worktree).await?,
3760        "landing worktree is dirty"
3761    );
3762    if state.gate_head.as_deref() != Some(&head) || !state.gate_ran || !state.gate_allows_progress()
3763    {
3764        state.gate.clear();
3765        state.gate_head = None;
3766        state.gate_ran = false;
3767        state.pre_gate.clear();
3768        state.pre_gate_commit = None;
3769        state.save()?;
3770        let inherited = crate::repo_lock::WaitPolicy::current();
3771        let wait = crate::repo_lock::WaitPolicy::new(
3772            Duration::from_secs(state.config.graph.timeout_resource_wait),
3773            move || inherited.cancelled(),
3774        );
3775        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3776        loop {
3777            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3778                state.gate = outcomes.clone();
3779                check_landing_resources(state, "gate", &outcomes)?;
3780            }
3781            if outcomes.iter().all(|o| o.ok()) {
3782                break;
3783            }
3784            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3785                crate::graph::GateFix::Retry => {
3786                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3787                }
3788                crate::graph::GateFix::Stop => break,
3789                crate::graph::GateFix::Defer => {
3790                    // The repair checks e2e immediately, before the next gate.
3791                    // Preserve a cache wait here as well as below; otherwise
3792                    // the daemon charges an attempt for unstarted verification.
3793                    if let Some(e2e) = &state.gate_repair_e2e {
3794                        let outcomes = e2e.outcomes.clone();
3795                        check_landing_resources(state, "e2e", &outcomes)?;
3796                    }
3797                    bail!("landing gate repair deferred");
3798                }
3799            }
3800        }
3801        state.gate = outcomes;
3802        state.gate_ran = true;
3803        state.gate_head = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3804        state.save()?;
3805    }
3806    anyhow::ensure!(
3807        state.gate_allows_progress(),
3808        "landing gate failed; nothing published"
3809    );
3810    let checked = state
3811        .gate_head
3812        .clone()
3813        .context("landing gate has no checked SHA")?;
3814    anyhow::ensure!(
3815        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3816            && git::is_clean(&winner.worktree).await?,
3817        "landing tree changed after gate"
3818    );
3819    let inherited = crate::repo_lock::WaitPolicy::current();
3820    let wait = crate::repo_lock::WaitPolicy::new(
3821        Duration::from_secs(state.config.graph.timeout_resource_wait),
3822        move || inherited.cancelled(),
3823    );
3824    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3825    if matches!(e2e, crate::graph::GateFix::Defer) {
3826        let outcomes = state
3827            .gate_repair_e2e
3828            .as_ref()
3829            .context("deferred landing e2e has no outcomes")?
3830            .outcomes
3831            .clone();
3832        check_landing_resources(state, "e2e", &outcomes)?;
3833    }
3834    anyhow::ensure!(
3835        matches!(e2e, crate::graph::GateFix::Retry),
3836        "post-repair e2e has not passed on the landing head; nothing published"
3837    );
3838    anyhow::ensure!(
3839        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3840            && git::is_clean(&winner.worktree).await?,
3841        "landing tree changed during post-repair e2e"
3842    );
3843    crate::pr_head::ensure_owner(state)?;
3844    if owner_requested_stop(state)? {
3845        bail!("owner requested stop before publication");
3846    }
3847    let current = forge.view(&state.repo, pr_url).await?;
3848    anyhow::ensure!(
3849        current.pr.state == PrLifecycle::Open,
3850        "PR closed or merged while gating; nothing published"
3851    );
3852    anyhow::ensure!(
3853        current.head == observed || current.head == checked,
3854        "PR head changed while gating; nothing published"
3855    );
3856    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3857    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3858    let refspec = format!("{checked}:refs/heads/{public}");
3859    let lock = repo_merge_lock(&state.repo);
3860    let _slot = lock.lock().await;
3861    let pushed = git::git_raw(
3862        &winner.worktree,
3863        &["push", &lease, &state.config.merge.remote, &refspec],
3864    )
3865    .await?;
3866    anyhow::ensure!(
3867        pushed.ok(),
3868        "publishing gated landing head failed: {}",
3869        pushed.stderr
3870    );
3871    state.pr_pushed_head = Some(checked.clone());
3872    state.land_sync_pending = false;
3873    state.save()?;
3874    Ok(checked)
3875}
3876
3877/// A scratch rebase moves the winner ref before its index and files. Recover
3878/// only a tree proven unchanged since the saved start; never discard edits.
3879async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3880    let winner = state.winner().cloned().context("landing has no winner")?;
3881    let inherited = crate::repo_lock::WaitPolicy::current();
3882    let wait = crate::repo_lock::WaitPolicy::new(
3883        Duration::from_secs(state.config.graph.timeout_resource_wait),
3884        move || inherited.cancelled(),
3885    );
3886    let _coordination = crate::repo_lock::SeatLease::operation_with(
3887        &winner.worktree,
3888        state.config.coordination.as_ref(),
3889        &wait,
3890    )
3891    .await?;
3892    if !git::is_clean(&winner.worktree).await? {
3893        let from = state
3894            .land_rebase_from
3895            .as_deref()
3896            .context("landing worktree is dirty")?;
3897        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3898        anyhow::ensure!(head != from, "landing worktree is dirty");
3899        for args in [
3900            vec!["diff", "--cached", "--quiet", from],
3901            vec!["diff", "--quiet"],
3902        ] {
3903            anyhow::ensure!(
3904                git::git_raw(&winner.worktree, &args).await?.ok(),
3905                "landing worktree has edits beyond the interrupted rebase"
3906            );
3907        }
3908        // Preserve build artifacts and local files, including ignored ones.
3909        // read-tree can overwrite ignored paths, so reject collisions with
3910        // the new tree (including file/directory conflicts) before updating.
3911        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
3912        let tracked = git::git(
3913            &winner.worktree,
3914            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
3915        )
3916        .await?;
3917        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
3918            anyhow::ensure!(
3919                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
3920                    path == local
3921                        || path
3922                            .strip_prefix(local)
3923                            .is_some_and(|rest| rest.starts_with('/'))
3924                        || local
3925                            .strip_prefix(path)
3926                            .is_some_and(|rest| rest.starts_with('/'))
3927                }),
3928                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
3929            );
3930        }
3931        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
3932    }
3933    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
3934    state.land_rebase_from = None;
3935    state.save()?;
3936    Ok(())
3937}
3938
3939async fn rebase_landing_worktree(
3940    state: &mut RunState,
3941    onto: &str,
3942) -> Result<crate::rebase::Rebased> {
3943    restore_landing_worktree(state).await?;
3944    let winner = state.winner().cloned().context("landing has no winner")?;
3945    state.land_sync_pending = true;
3946    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3947    state.save()?;
3948    let scratch = state.dir().join("rebase");
3949    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
3950    if result == crate::rebase::Rebased::Applied {
3951        restore_landing_worktree(state).await?;
3952    }
3953    Ok(result)
3954}
3955
3956/// Reconcile a stopped landing before changing its branch. This also handles
3957/// old records whose graph status or merge detail no longer identifies land.
3958async fn resume_landing_head<F: Forge>(
3959    state: &mut RunState,
3960    observed: &str,
3961    pr_url: &str,
3962    forge: &F,
3963) -> Result<String> {
3964    crate::pr_head::ensure_owner(state)?;
3965    let winner = state.winner().cloned().context("landing has no winner")?;
3966    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
3967    let expected = state
3968        .pr_pushed_head
3969        .as_deref()
3970        .or(state.gate_head.as_deref())
3971        .context("missing public head lease")?;
3972    anyhow::ensure!(
3973        observed == expected
3974            || (observed == local
3975                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
3976                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
3977        "public head moved outside this run; refusing to sync"
3978    );
3979    // An operator may have extended and published the winner while approval
3980    // was pending. Matching local/public history can be adopted as the lease,
3981    // but publish_landing_head must gate its new SHA before proceeding. A
3982    // remote-only or divergent update still cannot be overwritten.
3983    // Save reconciliation before rebasing again; a prior push may have reached
3984    // the remote immediately before the previous driver's last save.
3985    state.pr_pushed_head = Some(observed.to_owned());
3986    state.save()?;
3987    restore_landing_worktree(state).await?;
3988    let remote = state.config.merge.remote.clone();
3989    let base = state.base_branch.clone();
3990    let fetched = git::fetch(&state.repo, &remote, &base).await?;
3991    anyhow::ensure!(
3992        fetched.ok(),
3993        "cannot fetch landing base: {}",
3994        fetched.stderr
3995    );
3996    let onto = format!("{remote}/{base}");
3997    let tip = git::rev_parse(&state.repo, &onto).await?;
3998    if !git::is_ancestor(&state.repo, &tip, &local).await {
3999        match rebase_landing_worktree(state, &onto).await? {
4000            crate::rebase::Rebased::Applied => {}
4001            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4002        }
4003    }
4004    publish_landing_head(state, observed, pr_url, forge).await
4005}
4006
4007async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4008    crate::pr_head::ensure_owner(state)?;
4009    state.land_unrelated = None;
4010    let reopening_hold = crate::merge_decision::resumable_hold(state);
4011    let repo = state.repo.clone();
4012    let budget = state.config.graph.land_rounds;
4013    let mut round = 0usize;
4014    // Counted apart from `round`: a rebase is not a fix, and a base that
4015    // moved is not the change's fault.
4016    let mut rebases = 0usize;
4017    let ci = state.config.merge.ci.expectation();
4018    if ci == CiExpectation::Absent {
4019        state.event(
4020            "land",
4021            "CI not expected ([merge] ci = \"none\"); check status ignored",
4022        );
4023    }
4024    let mut waited = Duration::ZERO;
4025    // Comment bodies the fixer has already been shown. A comment is
4026    // outstanding until it has been handed over once; after that it is a
4027    // recorded decision, not an open question, and re-feeding it would loop the
4028    // budget away on a comment the fixer already declined with an argument.
4029    let mut shown: BTreeSet<String> = BTreeSet::new();
4030    // Await the last successfully published head, including across resumes.
4031    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4032    // Whether a settled-checks refusal has already been given its one re-look.
4033    let mut rechecked = false;
4034
4035    // Marks the run resumable through exactly this function, not through a
4036    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4037    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4038    // status specifically to know a resumed run belongs back in `land`
4039    // rather than at a second `gh pr create`. Set on every entry - fresh or
4040    // resumed - because a resume that parked here again must keep reading
4041    // `Landing`, not whatever a first pass through `merge` left behind.
4042    state.status = RunStatus::Landing;
4043    state.event("land", format!("watching {pr_url}"));
4044    state.save()?;
4045
4046    // An armed merge recorded by an earlier pass may or may not have reached
4047    // the forge (the record is written before the call). It is taken back on
4048    // the first observation, where a pull request is known to stop with.
4049    let mut resumed_armed = state.land_armed_head.is_some();
4050
4051    loop {
4052        let seen = forge.view(&repo, pr_url).await?;
4053        let mut pr = seen.pr.clone();
4054        pr.review_comments.retain(|c| !shown.contains(&c.body));
4055        state.pr = Some(crate::run::PrRecord {
4056            url: pr.url.clone(),
4057            number: pr.number,
4058            state: pr.state.as_str().to_owned(),
4059            checks: pr.checks.as_str().to_owned(),
4060            round,
4061            rounds: budget,
4062            red_at_merge: Vec::new(),
4063        });
4064        state.save()?;
4065
4066        if pr.state == PrLifecycle::Open {
4067            state.observe_branch_owner_holds(&seen.head).await;
4068            state.save()?;
4069        }
4070        crate::pr_head::ensure_owner(state)?;
4071        if reopening_hold {
4072            anyhow::ensure!(
4073                pr.state == PrLifecycle::Open,
4074                "held PR is no longer open; cannot request a fresh decision"
4075            );
4076            // An explicit resume authorizes a fresh question only. Preserve the
4077            // answered card, and never enter repair/rebase/merge on this pass.
4078            disarm(forge, state, &repo, pr.number)
4079                .await
4080                .map_err(anyhow::Error::msg)?;
4081            let previous = state.merge_decision.take();
4082            let binding = state.land_approval.take();
4083            state.config.graph.land_approval = true;
4084            let subject = merge_subject(
4085                crate::graph::landing_title(state, &seen.title),
4086                &crate::graph::landing_subject_source(state),
4087            );
4088            let contested = contested_to_ask(state);
4089            if let Err(error) =
4090                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4091            {
4092                state.merge_decision = previous;
4093                state.land_approval = binding;
4094                return Err(error);
4095            }
4096            state.end_pass_parked(crate::run::ParkCause::Question);
4097            state.parked = true;
4098            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()));
4099            state.save()?;
4100            return Ok(pr);
4101        }
4102        if owner_stop(state, &mut pr, forge).await? {
4103            return Ok(pr);
4104        }
4105
4106        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4107            // An approval already given for the same head is reused, so
4108            // nothing is asked twice. A failed disable
4109            // stops the run with the record kept: pushing on could change the
4110            // head under an arm that is still live.
4111            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4112                let why = format!(
4113                    "a previous pass may have armed auto-merge and it could not be disabled \
4114                     on resume: {e}"
4115                );
4116                stop(state, &repo, &pr, &why).await?;
4117                return Ok(pr);
4118            }
4119        }
4120
4121        // The head moved away from the one auto-merge was armed on, by
4122        // someone other than this loop. The arm is pinned and would refuse to
4123        // merge the new commit, but the approval was for the old one: take it
4124        // back and go through the normal path again.
4125        if pr.state == PrLifecycle::Open
4126            && !seen.head.is_empty()
4127            && state
4128                .land_armed_head
4129                .as_deref()
4130                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4131        {
4132            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4133                let why = format!(
4134                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4135                );
4136                stop(state, &repo, &pr, &why).await?;
4137                return Ok(pr);
4138            }
4139        }
4140
4141        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4142            // Disarming can outlive an owner answer. Consume it before our
4143            // own rebase invalidates the head shown on the decision card.
4144            if owner_stop(state, &mut pr, forge).await? {
4145                return Ok(pr);
4146            }
4147            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4148                binding.head.eq_ignore_ascii_case(&seen.head)
4149                    && ask::Questions::open()
4150                        .get(&binding.question)
4151                        .is_ok_and(|q| {
4152                            q.run == state.id
4153                                && q.node == APPROVAL_NODE
4154                                && q.status == ask::QuestionStatus::Answered
4155                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4156                        })
4157            });
4158            if repair_answer {
4159                let subject = merge_subject(
4160                    crate::graph::landing_title(state, &seen.title),
4161                    &crate::graph::landing_subject_source(state),
4162                );
4163                let contested = contested_to_ask(state);
4164                if matches!(
4165                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4166                    ApprovalGate::Repair
4167                ) {
4168                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4169                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4170                        state.save()?;
4171                    }
4172                    return Ok(pr);
4173                }
4174            }
4175            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4176            // A stop detected before publication must apply its durable
4177            // decision, even when verification returned an error meanwhile.
4178            if owner_stop(state, &mut pr, forge).await? {
4179                return Ok(pr);
4180            }
4181            let head = synced?;
4182            awaiting_head = Some(head);
4183            waited = Duration::ZERO;
4184            rechecked = false;
4185            // Read CI and approvals afresh, never from the pre-push observation.
4186            continue;
4187        }
4188
4189        if pr.state == PrLifecycle::Open {
4190            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4191                if waited >= WAIT_CEILING {
4192                    let want = awaiting_head.as_deref().unwrap_or_default();
4193                    let why = format!(
4194                        "the pull request's checks were still not about one readable head after \
4195                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4196                         someone may have pushed over it",
4197                        WAIT_CEILING.as_secs() / 60,
4198                        if want.is_empty() { "any" } else { want },
4199                        if seen.head.is_empty() {
4200                            "nothing readable"
4201                        } else {
4202                            &seen.head
4203                        },
4204                        if seen.rollup_head.is_empty() {
4205                            "nothing readable"
4206                        } else {
4207                            &seen.rollup_head
4208                        },
4209                    );
4210                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4211                    return Ok(pr);
4212                }
4213                waited += POLL;
4214                forge.poll().await;
4215                continue;
4216            }
4217            // Reset once, when the awaited head first shows up; resetting on
4218            // every re-observation would let a standing refusal wait forever.
4219            if awaiting_head.take().is_some() {
4220                // The checks now being read belong to the new head; give them
4221                // the same grace a fresh pull request gets.
4222                waited = Duration::ZERO;
4223            }
4224        }
4225
4226        // The merge-state grace is head-bound and durable. Reruns reset the
4227        // checks clock, but must not extend this deadline across resumes.
4228        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4229            let first = state
4230                .land_unsaid
4231                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4232            if !first.0.eq_ignore_ascii_case(&seen.head) {
4233                *first = (seen.head.clone(), forge.now());
4234            }
4235            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4236            state.save()?;
4237            elapsed
4238        } else {
4239            waited
4240        };
4241        let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4242        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4243            if waited >= WAIT_CEILING {
4244                stop_disarmed(
4245                    forge,
4246                    state,
4247                    &repo,
4248                    &pr,
4249                    "merge state remained unreadable; refusing to merge on a guess",
4250                )
4251                .await?;
4252                return Ok(pr);
4253            }
4254            waited += POLL;
4255            forge.poll().await;
4256            continue;
4257        }
4258
4259        // A failed workflow run is rerun once, judged by its own run rather
4260        // than by the rollup's aggregate: another run still pending, or a fix
4261        // budget already spent, must not keep a flaky failure from its one
4262        // rerun. It spends no round and no attempt.
4263        if ci == CiExpectation::Expected
4264            && pr.state == PrLifecycle::Open
4265            && state.config.graph.rerun_flaky_checks
4266        {
4267            let include_optional = pr.checks == Checks::Red
4268                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4269            let failing = rerunnable_with(&seen.contexts, include_optional);
4270            let now = forge.now();
4271            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4272                RerunStep::Rerun(runs) => {
4273                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4274                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4275                        *rec = LandReruns {
4276                            head: seen.head.clone(),
4277                            runs: BTreeMap::new(),
4278                        };
4279                    }
4280                    for run in &runs {
4281                        rec.runs.insert(run.clone(), now);
4282                    }
4283                    // Durable before the call: a restart must not
4284                    // rerun the same run twice. A save that fails
4285                    // means no rerun.
4286                    state.save()?;
4287                    let mut any = false;
4288                    for run in &runs {
4289                        let names = failing
4290                            .iter()
4291                            .filter(|(_, r)| r.as_deref() == Some(run))
4292                            .map(|(l, _)| l.as_str())
4293                            .collect::<Vec<_>>()
4294                            .join(", ");
4295                        match forge.rerun(&repo, run).await {
4296                            Ok(()) => {
4297                                any = true;
4298                                state.event(
4299                                    "land",
4300                                    format!("reran failed check(s) {names} (run {run})"),
4301                                );
4302                            }
4303                            // Stays recorded: never a retry loop.
4304                            Err(e) => state.event(
4305                                "land",
4306                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4307                            ),
4308                        }
4309                    }
4310                    state.save()?;
4311                    if any {
4312                        waited = Duration::ZERO;
4313                        forge.poll().await;
4314                        continue;
4315                    }
4316                }
4317                RerunStep::Grace => {
4318                    if waited >= WAIT_CEILING {
4319                        let why = format!(
4320                            "checks were still failing after a rerun and {} minutes",
4321                            WAIT_CEILING.as_secs() / 60
4322                        );
4323                        stop(state, &repo, &pr, &why).await?;
4324                        return Ok(pr);
4325                    }
4326                    waited += POLL;
4327                    forge.poll().await;
4328                    continue;
4329                }
4330                RerunStep::Fix => {}
4331            }
4332        }
4333
4334        let step = proposed;
4335        // Armed on exactly this head: the forge is doing the waiting. A
4336        // decision to merge (or keep waiting) is only watched, never re-armed
4337        // and never re-approved; anything else - a red check, a comment, a
4338        // conflict - falls through to its own arm, which disarms first.
4339        let armed_here = state
4340            .land_armed_head
4341            .as_deref()
4342            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4343        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4344            if waited >= WAIT_CEILING {
4345                let required = if seen.base.is_empty() {
4346                    None
4347                } else {
4348                    forge.required_contexts(&repo, &seen.base).await
4349                };
4350                let why = format!(
4351                    "auto-merge was armed on {} but the pull request did not merge within {} \
4352                     minutes ({})",
4353                    seen.head,
4354                    WAIT_CEILING.as_secs() / 60,
4355                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4356                );
4357                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4358                return Ok(pr);
4359            }
4360            waited += POLL;
4361            forge.poll().await;
4362            continue;
4363        }
4364        if armed_here && !matches!(step, Step::Done { .. }) {
4365            // Not merging or waiting any more: whatever is next may change the
4366            // head or give up, and neither may leave the arm standing.
4367            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4368                let why = format!("auto-merge could not be disabled: {e}");
4369                stop(state, &repo, &pr, &why).await?;
4370                return Ok(pr);
4371            }
4372        }
4373        if pr.checks == Checks::Red
4374            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4375            && pr.review_comments.is_empty()
4376            && let Some(evidence) = forge.attribution(state, &seen).await
4377            && evidence.unrelated(&seen.head, &pr.failing)
4378        {
4379            if owner_stop(state, &mut pr, forge).await? {
4380                return Ok(pr);
4381            }
4382            // Attribution does not authorize action against an unseen push.
4383            let fresh = forge.view(&repo, pr_url).await?;
4384            if fresh.head != seen.head
4385                || fresh.rollup_head != seen.rollup_head
4386                || fresh.pr.state != PrLifecycle::Open
4387                || fresh.pr.checks != Checks::Red
4388                || fresh.pr.failing != pr.failing
4389                || fresh
4390                    .pr
4391                    .review_comments
4392                    .iter()
4393                    .any(|c| !shown.contains(&c.body))
4394            {
4395                continue;
4396            }
4397            hold_unrelated(state, &pr, evidence).await?;
4398            return Ok(pr);
4399        }
4400        match step {
4401            Step::Wait => {
4402                if waited >= WAIT_CEILING {
4403                    let why = format!(
4404                        "checks were still running after {} minutes",
4405                        WAIT_CEILING.as_secs() / 60
4406                    );
4407                    stop(state, &repo, &pr, &why).await?;
4408                    return Ok(pr);
4409                }
4410                waited += POLL;
4411                forge.poll().await;
4412            }
4413            Step::Done { merged } => {
4414                if merged {
4415                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4416                }
4417                // Auto-merge is pinned to the approved head, but the forge's
4418                // own handling of a later push is not something magi can
4419                // see: if the pull request merged on another commit, say so
4420                // loudly rather than record a clean landing.
4421                if let Some(armed) = state.land_armed_head.take() {
4422                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4423                        let msg = format!(
4424                            "{} merged on {} but the owner approved {armed}; review what landed",
4425                            pr.url, seen.head
4426                        );
4427                        tracing::warn!("{msg}");
4428                        state.event("land", msg);
4429                        // Only an arm left by an older build can get here.
4430                        // The wording is fixed so a repeat does not relight
4431                        // the notice.
4432                        crate::notices::raise_with(
4433                            crate::notices::Notice::warn(
4434                                &format!("merged-unapproved-head:{}", state.id),
4435                                "A pull request merged on a commit the owner did not approve; \
4436                                 review what landed",
4437                            )
4438                            .link(crate::notices::Link::Run {
4439                                id: state.id.clone(),
4440                            }),
4441                            &state.config.notify,
4442                        );
4443                    }
4444                }
4445                state.status = if merged {
4446                    RunStatus::Merged
4447                } else {
4448                    RunStatus::Ready
4449                };
4450                let detail = if merged {
4451                    format!("{} was merged", pr.url)
4452                } else {
4453                    format!("{} was closed without merging", pr.url)
4454                };
4455                state.merge = Some(MergeOutcome {
4456                    mode: MergeMode::Pr,
4457                    ok: merged,
4458                    detail: detail.clone(),
4459                    empty: false,
4460                });
4461                state.event("land", detail);
4462                state.save()?;
4463                write_pr_state_through(state, pr.state);
4464                return Ok(pr);
4465            }
4466            Step::Merge => {
4467                state.check_delivery_evidence(&seen.head)?;
4468                let subject = merge_subject(
4469                    crate::graph::landing_title(state, &seen.title),
4470                    &crate::graph::landing_subject_source(state),
4471                );
4472                // The owner sees the panel before the one irreversible step,
4473                // and an unanswered question is a hold: silence never merges.
4474                //
4475                // The dedicated policy includes existing consent and post-repair
4476                // decisions; repair choice availability never grants merge authority.
4477                let contested = contested_to_ask(state);
4478                if !approval_reasons(state).is_empty() {
4479                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4480                        .await?
4481                    {
4482                        ApprovalGate::Approved => {}
4483                        ApprovalGate::Repair => {
4484                            // The driver consumes the saved request under the existing
4485                            // run/task claim. No merge, no second run or task.
4486                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4487                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4488                                state.save()?;
4489                            }
4490                            return Ok(pr);
4491                        }
4492                        ApprovalGate::Held => {
4493                            // Another entry point may have saved a stop after
4494                            // the first read above. Apply its durable action.
4495                            if owner_stop(state, &mut pr, forge).await? {
4496                                return Ok(pr);
4497                            }
4498                            stop(
4499                                state,
4500                                &repo,
4501                                &pr,
4502                                "the owner did not approve the merge (held or unanswered)",
4503                            )
4504                            .await?;
4505                            return Ok(pr);
4506                        }
4507                        // Filed (or still standing from an earlier visit) and
4508                        // not yet answered. Park here rather than wait: the
4509                        // question survives on disk, the daemon hands this
4510                        // run's slot to something else, and a later resume
4511                        // re-enters `land`, finds the same question, and
4512                        // either merges or stops depending on what it says
4513                        // by then.
4514                        ApprovalGate::Pending => {
4515                            state.end_pass_parked(crate::run::ParkCause::Question);
4516                            state.parked = true;
4517                            state.event(
4518                                "land",
4519                                "parked awaiting merge approval - resumes once answered",
4520                            );
4521                            state.save()?;
4522                            return Ok(pr);
4523                        }
4524                    }
4525                }
4526                // Open and past the gate above, so `seen.head` is the commit
4527                // the checks were bound to and the owner approved.
4528                //
4529                // Merge directly, bound to that commit. Auto-merge is not
4530                // armed any more: the forge checks `--match-head-commit` only
4531                // when the request is made, so an arm outlives the head it
4532                // was made for, and a push of another commit whose
4533                // requirements are met first would merge without approval.
4534                // A direct merge is checked by the forge at the moment it
4535                // happens, so only the approved head can land.
4536                let observed_head = seen.head.clone();
4537                // Read the pull request again just before: the state seen
4538                // above can be a moment old.
4539                {
4540                    let fresh = forge.view(&repo, pr_url).await.ok();
4541                    if !direct_merge_is_safe(
4542                        fresh.as_ref(),
4543                        &observed_head,
4544                        &shown,
4545                        round,
4546                        budget,
4547                        waited,
4548                        ci,
4549                    ) {
4550                        if waited >= WAIT_CEILING {
4551                            let why = "the pull request did not settle on the approved head \
4552                                       before it could be merged";
4553                            stop(state, &repo, &pr, why).await?;
4554                            return Ok(pr);
4555                        }
4556                        state.event(
4557                            "land",
4558                            "the pull request changed before merging; looking again",
4559                        );
4560                        waited += POLL;
4561                        forge.poll().await;
4562                        continue;
4563                    }
4564                }
4565                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4566                let out = {
4567                    let merge_lock = repo_merge_lock(&repo);
4568                    let _merge_slot = merge_lock.lock().await;
4569                    crate::pr_head::ensure_owner(state)?;
4570                    if let Some(task_id) = state
4571                        .origin
4572                        .as_ref()
4573                        .and_then(|origin| origin.task.as_ref())
4574                    {
4575                        let task = crate::queue::Queue::open()
4576                            .get(task_id)
4577                            .context("cannot verify the task owner immediately before merge")?;
4578                        if task.runs.last() != Some(&state.id)
4579                            || task.operator_held()
4580                            || task.status == crate::queue::TaskStatus::Done
4581                        {
4582                            bail!(
4583                                "task ownership or an operator hold superseded this merge approval"
4584                            );
4585                        }
4586                    }
4587                    if let Some(binding) = &state.land_approval {
4588                        let current = ask::Questions::open().get(&binding.question)?;
4589                        if merge_consent_superseded(state, &current)?
4590                            || current.run != state.id
4591                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4592                            || approval(current.resolution().as_deref()) != Approval::Merge
4593                            || current.merge_target.as_ref().is_some_and(|t| {
4594                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4595                            })
4596                        {
4597                            bail!(
4598                                "merge decision is stale or no longer authorizes the reviewed head"
4599                            );
4600                        }
4601                    }
4602                    forge.merge(&repo, &argv).await?
4603                };
4604                if out.0 {
4605                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4606                    // enqueues (or reports the pull request already queued)
4607                    // and succeeds while it is still open. Ask the forge; an
4608                    // unreadable answer is not a confirmation either, so it
4609                    // is looked at again rather than recorded as merged.
4610                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4611                        if c.pr.state == PrLifecycle::Merged {
4612                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4613                            true
4614                        } else {
4615                            false
4616                        }
4617                    });
4618                    if !confirmed {
4619                        if waited >= WAIT_CEILING {
4620                            let why = "the merge request succeeded but the pull request \
4621                                       could not be confirmed merged after waiting";
4622                            stop(state, &repo, &pr, why).await?;
4623                            return Ok(pr);
4624                        }
4625                        state.event(
4626                            "land",
4627                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4628                        );
4629                        state.save()?;
4630                        waited += POLL;
4631                        forge.poll().await;
4632                        continue;
4633                    }
4634                    pr.state = PrLifecycle::Merged;
4635                    state.status = RunStatus::Merged;
4636                    state.merge = Some(MergeOutcome {
4637                        mode: MergeMode::Pr,
4638                        ok: true,
4639                        detail: format!("gh {}", argv.join(" ")),
4640                        empty: false,
4641                    });
4642                    // The last `state.pr` snapshot is whatever the poll before
4643                    // this merge observed - still `open` - and nothing below
4644                    // refreshes it from GitHub again, so the UI's round rail
4645                    // would otherwise keep animating a merged run forever.
4646                    if let Some(pr_record) = state.pr.as_mut() {
4647                        pr_record.state = pr.state.as_str().to_owned();
4648                    }
4649                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4650                    announce_red_merge(state, &pr).await;
4651                    state.save()?;
4652                    write_pr_state_through(state, pr.state);
4653                    return Ok(pr);
4654                }
4655                let after_seen = forge.view(&repo, pr_url).await.ok();
4656                let after = after_seen.as_ref().map(|s| s.pr.state);
4657                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4658                    state.pr_merged_head = after_seen
4659                        .as_ref()
4660                        .map(|s| s.head.clone())
4661                        .filter(|h| !h.is_empty());
4662                    pr.state = PrLifecycle::Merged;
4663                    state.status = RunStatus::Merged;
4664                    state.merge = Some(outcome);
4665                    if let Some(pr_record) = state.pr.as_mut() {
4666                        pr_record.state = pr.state.as_str().to_owned();
4667                    }
4668                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4669                    announce_red_merge(state, &pr).await;
4670                    state.save()?;
4671                    write_pr_state_through(state, pr.state);
4672                    return Ok(pr);
4673                }
4674                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4675                match verdict {
4676                    Refused::Final => {
4677                        let merge_state = after_seen
4678                            .as_ref()
4679                            .map(|s| s.merge_state.as_str())
4680                            .filter(|m| !m.is_empty())
4681                            .unwrap_or("unknown");
4682                        // Which refusal this was decides what a person has to
4683                        // do about it, so the two are worded apart; both carry
4684                        // the forge's own message.
4685                        let why = format!(
4686                            "the merge was refused: {} (merge state: {merge_state})",
4687                            out.1
4688                        );
4689                        stop(state, &repo, &pr, &why).await?;
4690                        return Ok(pr);
4691                    }
4692                    verdict => {
4693                        // Back to the top, which re-decides and passes the
4694                        // approval gate again, a poll later. `waited` is not
4695                        // reset here: only a pushed fix restarts it, or a
4696                        // standing refusal would wait forever.
4697                        if waited >= WAIT_CEILING {
4698                            let why = format!(
4699                                "the merge was still refused after {} minutes: {}",
4700                                WAIT_CEILING.as_secs() / 60,
4701                                out.1
4702                            );
4703                            stop(state, &repo, &pr, &why).await?;
4704                            return Ok(pr);
4705                        }
4706                        if verdict == Refused::Recheck {
4707                            rechecked = true;
4708                        }
4709                        state.event(
4710                            "land",
4711                            "merge refused while the branch policy is not satisfied yet; waiting",
4712                        );
4713                        state.save()?;
4714                        waited += POLL;
4715                        forge.poll().await;
4716                    }
4717                }
4718            }
4719            Step::Rebase => {
4720                // Bounded by the same budget as a fix, because a rebase that
4721                // keeps being needed means the base moves faster than this
4722                // run can land and a person should decide what to do. It
4723                // spends none of that budget: the change is not what is
4724                // wrong.
4725                if rebases >= budget {
4726                    let why = format!(
4727                        "the base moved under this branch {budget} time(s) and it still does \
4728                         not merge; rebasing again would only race it"
4729                    );
4730                    stop(state, &repo, &pr, &why).await?;
4731                    return Ok(pr);
4732                }
4733                rebases += 1;
4734                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4735                    stop(
4736                        state,
4737                        &repo,
4738                        &pr,
4739                        "the pull request conflicts and this run has no winning branch to rebase",
4740                    )
4741                    .await?;
4742                    return Ok(pr);
4743                };
4744                let base = state.base_branch.clone();
4745                state.event(
4746                    "land",
4747                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4748                );
4749                state.save()?;
4750
4751                // Onto the base as the *remote* has it: the local ref may be
4752                // behind, and rebasing onto a stale base produces a branch
4753                // that conflicts all over again.
4754                let remote = state.config.merge.remote.clone();
4755                git::fetch(&repo, &remote, &base).await.ok();
4756                if owner_stop(state, &mut pr, forge).await? {
4757                    return Ok(pr);
4758                }
4759                let onto = format!("{remote}/{base}");
4760                let rebased = match rebase_landing_worktree(state, &onto).await {
4761                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4762                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4763                    Err(e) => Err(e),
4764                };
4765                if owner_stop(state, &mut pr, forge).await? {
4766                    return Ok(pr);
4767                }
4768                match rebased {
4769                    Ok(None) => {
4770                        let published =
4771                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4772                        if owner_stop(state, &mut pr, forge).await? {
4773                            return Ok(pr);
4774                        }
4775                        let head = published?;
4776                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4777                        state.pr_pushed_head = Some(head.clone());
4778                        awaiting_head = Some(head);
4779                        rechecked = false;
4780                        state.event("land", format!("rebased {branch} onto {base}"));
4781                        state.save()?;
4782                        // The forge has to re-run its checks against the
4783                        // rebased head before anything else can be decided.
4784                        waited = Duration::ZERO;
4785                        tokio::time::sleep(POLL).await;
4786                    }
4787                    // The fixer's rounds are spent (or it could not finish):
4788                    // that is a decision for a person.
4789                    Ok(Some(conflict)) => {
4790                        let why = format!(
4791                            "{} conflicts with {base} and the rebase did not apply: {}",
4792                            pr.url,
4793                            conflict.chars().take(600).collect::<String>()
4794                        );
4795                        stop(state, &repo, &pr, &why).await?;
4796                        return Ok(pr);
4797                    }
4798                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4799                    Err(e) => {
4800                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4801                        stop(state, &repo, &pr, &why).await?;
4802                        return Ok(pr);
4803                    }
4804                }
4805            }
4806            Step::GiveUp { reason } => {
4807                stop(state, &repo, &pr, &reason).await?;
4808                return Ok(pr);
4809            }
4810            Step::Fix { reason } => {
4811                round += 1;
4812                waited = Duration::ZERO;
4813                for c in &pr.review_comments {
4814                    shown.insert(c.body.clone());
4815                }
4816                state.event("land", format!("round {round}: {reason}"));
4817                state.save()?;
4818
4819                let logs = failing_logs(&repo, &seen.failing_urls).await;
4820                let was_red = pr.checks == Checks::Red;
4821                if owner_stop(state, &mut pr, forge).await? {
4822                    return Ok(pr);
4823                }
4824                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4825                if owner_stop(state, &mut pr, forge).await? {
4826                    return Ok(pr);
4827                }
4828                match fixed {
4829                    Fixed::Committed { head } => {
4830                        // Whatever the next look shows may still be the
4831                        // previous head; see `awaiting_new_head`.
4832                        state.pr_pushed_head = Some(head.clone());
4833                        awaiting_head = Some(head);
4834                        rechecked = false;
4835                        waited = Duration::ZERO;
4836                        forge.poll().await;
4837                    }
4838                    Fixed::Declined if was_red => {
4839                        // Review comments may have required this round even
4840                        // when CI was unrelated. Once the fixer has read them,
4841                        // use the same structural attribution before charging
4842                        // a no-commit stop to the task.
4843                        if let Some(evidence) = forge.attribution(state, &seen).await
4844                            && evidence.unrelated(&seen.head, &pr.failing)
4845                        {
4846                            let fresh = forge.view(&repo, pr_url).await?;
4847                            if fresh.head != seen.head
4848                                || fresh.rollup_head != seen.rollup_head
4849                                || fresh.pr.state != PrLifecycle::Open
4850                                || fresh.pr.checks != Checks::Red
4851                                || fresh.pr.failing != pr.failing
4852                                || fresh
4853                                    .pr
4854                                    .review_comments
4855                                    .iter()
4856                                    .any(|c| !shown.contains(&c.body))
4857                            {
4858                                continue;
4859                            }
4860                            if owner_stop(state, &mut pr, forge).await? {
4861                                return Ok(pr);
4862                            }
4863                            hold_unrelated(state, &pr, evidence).await?;
4864                            return Ok(pr);
4865                        }
4866                        let why = format!(
4867                            "the fixer produced no commit while {} check(s) were failing \
4868                             ({}); stopping instead of looping on an unchanged tree",
4869                            pr.failing.len(),
4870                            pr.failing.join(", ")
4871                        );
4872                        stop(state, &repo, &pr, &why).await?;
4873                        return Ok(pr);
4874                    }
4875                    // Comment-driven round with no commit: the fixer read the
4876                    // comments and changed nothing, which is a decision it is
4877                    // allowed to make. The comments are recorded as shown, so
4878                    // the next observation sees a clean pull request.
4879                    Fixed::Declined => state.event(
4880                        "land",
4881                        format!("round {round}: fixer declined the comments, nothing committed"),
4882                    ),
4883                    Fixed::Failed(why) => {
4884                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4885                        return Ok(pr);
4886                    }
4887                }
4888                state.save()?;
4889            }
4890        }
4891    }
4892}
4893
4894/// Owner decision for a CI failure independently reproduced on the base.
4895pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
4896
4897/// Durable machine-hold evidence, bound to one observed PR head.
4898#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4899pub struct CiHold {
4900    /// Head that exhibited the independently reproduced failures.
4901    pub head: String,
4902    /// Typed owner question controlling resumption.
4903    pub question: String,
4904    /// Complete structured facts used to attribute the failures.
4905    pub evidence: CiEvidence,
4906}
4907
4908/// Only structured forge annotations are compared, never log prose.
4909#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4910struct CiFailure {
4911    path: String,
4912    start_line: u64,
4913    end_line: u64,
4914    title: String,
4915    message: String,
4916}
4917
4918#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4919struct CiCheckEvidence {
4920    label: String,
4921    head_check: u64,
4922    base_check: u64,
4923    app: u64,
4924    run: String,
4925    base_run: String,
4926    head_failures: Vec<CiFailure>,
4927    base_failures: Vec<CiFailure>,
4928    rerun_exhausted: bool,
4929}
4930
4931/// Pinned Git diff and forge failure annotations used for CI attribution.
4932#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4933pub struct CiEvidence {
4934    head: String,
4935    base: String,
4936    changed: Vec<String>,
4937    checks: Vec<CiCheckEvidence>,
4938}
4939
4940impl CiEvidence {
4941    /// Every red check must have the same located failure on the base, outside
4942    /// the diff. Changes to CI/build configuration invalidate this inference.
4943    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
4944        if self.head != head
4945            || self.base.is_empty()
4946            || failing.is_empty()
4947            || self.changed.iter().any(|p| {
4948                p.starts_with(".github/")
4949                    || p.starts_with(".config/")
4950                    || p.starts_with(".cargo/")
4951                    || matches!(
4952                        p.rsplit('/').next().unwrap_or(p),
4953                        "Cargo.toml"
4954                            | "Cargo.lock"
4955                            | "Makefile.toml"
4956                            | "build.rs"
4957                            | "rust-toolchain.toml"
4958                    )
4959            })
4960        {
4961            return false;
4962        }
4963        failing.iter().all(|label| {
4964            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
4965            matches.len() == 1
4966                && matches[0].rerun_exhausted
4967                && !matches[0].head_failures.is_empty()
4968                && matches[0].head_failures.iter().all(|f| {
4969                    // Workflow-level exit-code annotations do not identify a
4970                    // failing test or source file, and prove nothing here.
4971                    !f.path.is_empty()
4972                        && !f.path.starts_with('.')
4973                        && !f.path.contains("..")
4974                        && !f.path.starts_with('/')
4975                        && !self.changed.contains(&f.path)
4976                        && matches[0].base_failures.contains(f)
4977                })
4978        })
4979    }
4980}
4981
4982async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
4983    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
4984    if !ok {
4985        return None;
4986    }
4987    serde_json::from_str(&body).ok()
4988}
4989
4990async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
4991    let count = check.pointer("/output/annotations_count")?.as_u64()?;
4992    if count == 0 || count >= 100 {
4993        return None;
4994    }
4995    let id = check.get("id")?.as_u64()?;
4996    let value = ci_json(
4997        repo,
4998        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
4999    )
5000    .await?;
5001    let annotations = value.as_array()?;
5002    if annotations.len() != count as usize {
5003        return None;
5004    }
5005    annotations
5006        .iter()
5007        .filter(|a| a["annotation_level"] == "failure")
5008        .map(|a| {
5009            Some(CiFailure {
5010                path: a["path"].as_str()?.into(),
5011                start_line: a["start_line"].as_u64()?,
5012                end_line: a["end_line"].as_u64()?,
5013                title: a["title"].as_str()?.into(),
5014                message: a["message"].as_str()?.into(),
5015            })
5016        })
5017        .collect()
5018}
5019
5020async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5021    let value = ci_json(
5022        repo,
5023        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5024    )
5025    .await?;
5026    let runs = value["check_runs"].as_array()?;
5027    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5028        return None;
5029    }
5030    Some(runs.clone())
5031}
5032
5033async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5034    let repo = &state.repo;
5035    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5036    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5037        .await
5038        .ok()?;
5039    if !base.ok() {
5040        return None;
5041    }
5042    let range = format!("{}...{}", base.stdout, seen.head);
5043    // No rename detection: both old and new paths must count as changed.
5044    let diff = git::git_raw(
5045        repo,
5046        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5047    )
5048    .await
5049    .ok()?;
5050    if !diff.ok() {
5051        return None;
5052    }
5053    let head_runs = ci_runs(repo, &seen.head).await?;
5054    let base_runs = ci_runs(repo, &base.stdout).await?;
5055    let mut checks = Vec::new();
5056    for label in &seen.pr.failing {
5057        let matching = |runs: &[serde_json::Value]| {
5058            runs.iter()
5059                .filter(|r| r["name"].as_str() == Some(label))
5060                .cloned()
5061                .collect::<Vec<_>>()
5062        };
5063        let heads = matching(&head_runs);
5064        let bases = matching(&base_runs);
5065        if heads.len() != 1 || bases.len() != 1 {
5066            return None;
5067        }
5068        let h = &heads[0];
5069        let b = &bases[0];
5070        if h["conclusion"] != "failure"
5071            || b["conclusion"] != "failure"
5072            || h["status"] != "completed"
5073            || b["status"] != "completed"
5074            || h["head_sha"].as_str()? != seen.head
5075            || b["head_sha"].as_str()? != base.stdout
5076            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5077        {
5078            return None;
5079        }
5080        let run = run_of(h["details_url"].as_str()?)?;
5081        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5082        if contexts.len() != 1
5083            || contexts[0].run.as_ref() != Some(&run)
5084            || contexts[0].verdict != Verdict::Fail
5085        {
5086            return None;
5087        }
5088        // A persisted request alone is not a completed rerun: require the
5089        // workflow's structured attempt number as well.
5090        let workflow =
5091            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5092        let base_run = run_of(b["details_url"].as_str()?)?;
5093        let base_workflow = ci_json(
5094            repo,
5095            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5096        )
5097        .await?;
5098        if base_workflow["head_sha"].as_str()? != base.stdout
5099            || base_workflow["head_branch"].as_str()? != seen.base
5100            || base_workflow["status"] != "completed"
5101            || base_workflow["conclusion"] != "failure"
5102            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5103        {
5104            return None;
5105        }
5106        let exhausted = state
5107            .land_reruns
5108            .as_ref()
5109            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5110            && workflow["head_sha"].as_str()? == seen.head
5111            && workflow["run_attempt"].as_u64()? > 1
5112            && workflow["status"] == "completed";
5113        checks.push(CiCheckEvidence {
5114            label: label.clone(),
5115            head_check: h["id"].as_u64()?,
5116            base_check: b["id"].as_u64()?,
5117            app: h.pointer("/app/id")?.as_u64()?,
5118            run,
5119            base_run,
5120            head_failures: ci_annotations(repo, h).await?,
5121            base_failures: ci_annotations(repo, b).await?,
5122            rerun_exhausted: exhausted,
5123        });
5124    }
5125    Some(CiEvidence {
5126        head: seen.head.clone(),
5127        base: base.stdout,
5128        changed: diff
5129            .stdout
5130            .split('\0')
5131            .filter(|p| !p.is_empty())
5132            .map(str::to_owned)
5133            .collect(),
5134        checks,
5135    })
5136}
5137
5138async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5139    let detail = serde_json::to_string(&evidence)?;
5140    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5141    let hold = "operator: 保留して後で判断する".to_owned();
5142    let mut q = ask::Question::new(
5143        state.id.clone(),
5144        CI_HOLD_NODE.into(),
5145        "land".into(),
5146        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5147        format!(
5148            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5149        ),
5150        vec![resume.clone(), hold.clone()],
5151    );
5152    q.actions.insert(
5153        resume,
5154        ask::ChoiceAction::Resume {
5155            run: state.id.clone(),
5156        },
5157    );
5158    q.actions.insert(hold, ask::ChoiceAction::Hold);
5159    ask::Questions::open().put_executable(&mut q)?;
5160    state.land_unrelated = Some(CiHold {
5161        head: evidence.head.clone(),
5162        question: q.id.clone(),
5163        evidence,
5164    });
5165    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5166    state.end_pass_parked(crate::run::ParkCause::Question);
5167    state.parked = true;
5168    state.save()?;
5169    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5170        tracing::warn!("could not notify about CI hold: {e:#}");
5171    }
5172    // A durable question and evidence are sufficient even if commenting fails.
5173    let body = stop_comment(
5174        &state.id,
5175        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5176    );
5177    let (_, body) = crate::github_text::prepare(state, "", &body);
5178    gh(
5179        &state.repo,
5180        &[
5181            "pr".into(),
5182            "comment".into(),
5183            pr.number.to_string(),
5184            "--body".into(),
5185            body,
5186        ],
5187    )
5188    .await
5189    .ok();
5190    Ok(())
5191}
5192
5193/// One observation, plus the two things [`PrState`] deliberately does not carry:
5194/// the title (needed for the squash subject) and where the failing checks'
5195/// logs live.
5196#[derive(Clone)]
5197struct Seen {
5198    pr: PrState,
5199    title: String,
5200    failing_urls: Vec<(String, String)>,
5201    /// `headRefOid`: the commit this observation, checks included, is about.
5202    head: String,
5203    /// The commit the checks belong to, read from the same GraphQL node as
5204    /// the checks themselves. Empty when unreadable.
5205    rollup_head: String,
5206    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5207    /// BEHIND and DRAFT together, and a stop reason has to say which.
5208    merge_state: String,
5209    /// Every check of the rollup, for naming what an armed merge waits on.
5210    contexts: Vec<CheckInfo>,
5211    /// `baseRefName`, to look up which contexts the base requires.
5212    base: String,
5213}
5214
5215/// One check of the rollup as far as a stop reason needs it.
5216#[derive(Clone)]
5217struct CheckInfo {
5218    label: String,
5219    verdict: Verdict,
5220    required: Option<bool>,
5221    /// The workflow run the check belongs to; `None` for an external status.
5222    run: Option<String>,
5223}
5224
5225/// What to do with a red head before a fix round is spent on it.
5226#[derive(Debug, Clone, PartialEq, Eq)]
5227enum RerunStep {
5228    /// Rerun these workflow runs (each not yet rerun for this head).
5229    Rerun(Vec<String>),
5230    /// Reran already and the forge may still report the old failure: wait.
5231    Grace,
5232    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5233    Fix,
5234}
5235
5236/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5237/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5238/// pending checks in another run do not delay this run's rerun.
5239#[cfg(test)]
5240fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5241    rerunnable_with(contexts, false)
5242}
5243
5244fn rerunnable_with(
5245    contexts: &[CheckInfo],
5246    include_optional: bool,
5247) -> Vec<(String, Option<String>)> {
5248    contexts
5249        .iter()
5250        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5251        // `gh run rerun --failed` refuses a run with a job still going.
5252        .filter(|c| {
5253            c.run.as_ref().is_none_or(|r| {
5254                !contexts.iter().any(|o| {
5255                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5256                        && o.run.as_ref() == Some(r)
5257                })
5258            })
5259        })
5260        .map(|c| (c.label.clone(), c.run.clone()))
5261        .collect()
5262}
5263
5264/// Pure: rerun once per run id, wait out the grace, then hand over.
5265fn rerun_step(
5266    failing: &[(String, Option<String>)],
5267    record: Option<&LandReruns>,
5268    head: &str,
5269    now: i64,
5270) -> RerunStep {
5271    // A failure with no workflow run (an external status) cannot be rerun, and
5272    // the fixer's push re-runs everything anyway.
5273    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5274        return RerunStep::Fix;
5275    }
5276    // Reruns belong to the head they were made on.
5277    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5278    let mut fresh: Vec<String> = Vec::new();
5279    let mut recent = false;
5280    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5281        match record.and_then(|r| r.runs.get(run)) {
5282            None => {
5283                if !fresh.iter().any(|f| f == run) {
5284                    fresh.push(run.to_owned());
5285                }
5286            }
5287            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5288            Some(_) => {}
5289        }
5290    }
5291    if !fresh.is_empty() {
5292        RerunStep::Rerun(fresh)
5293    } else if recent {
5294        RerunStep::Grace
5295    } else {
5296        RerunStep::Fix
5297    }
5298}
5299
5300/// Read the pull request: `gh pr view` for the top-level thread and merge
5301/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5302/// inline review comments `gh pr view` does not report.
5303async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5304    let view = gh(
5305        repo,
5306        &[
5307            "pr".to_owned(),
5308            "view".to_owned(),
5309            pr_url.to_owned(),
5310            "--json".to_owned(),
5311            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5312                .to_owned(),
5313        ],
5314    )
5315    .await?;
5316    if !view.0 {
5317        bail!("gh pr view {pr_url}: {}", view.1);
5318    }
5319    let number = parse_pr(&view.1)?.number;
5320    let node = last_commit_node(repo, number).await;
5321    let mut seen = seen_from(&view.1, node.as_deref())?;
5322
5323    let inline = gh(
5324        repo,
5325        &[
5326            "api".to_owned(),
5327            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5328        ],
5329    )
5330    .await?;
5331    if inline.0 {
5332        match parse_inline_comments(&inline.1) {
5333            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5334            // An unreadable inline thread must not end a landing: the rollup
5335            // and the top-level thread are still real signal.
5336            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5337        }
5338    } else {
5339        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5340    }
5341    Ok(seen)
5342}
5343
5344/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5345/// response. No I/O.
5346///
5347/// The commit oid and the checks are read from the *same* node, so the rollup
5348/// is bound to the commit it belongs to by construction; two reads that agree
5349/// before and after another response prove nothing about what that response
5350/// held. The view's own rollup is never used. A node that is missing,
5351/// unreadable, carries GraphQL errors, or whose checks run past the page
5352/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5353/// as "look again" and never as a reason to merge.
5354fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5355    let mut pr = parse_pr(view_json)?;
5356    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5357
5358    let mut rollup_head = String::new();
5359    let mut failing_urls = Vec::new();
5360    let mut contexts = Vec::new();
5361    let mut checks = Checks::Unknown;
5362    let mut failing = Vec::new();
5363    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5364        (checks, failing) = rollup_verdict(&rollup);
5365        failing_urls = rollup
5366            .iter()
5367            .filter(|c| c.verdict() == Verdict::Fail)
5368            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5369            .collect();
5370        contexts = rollup
5371            .iter()
5372            .map(|c| CheckInfo {
5373                label: c.label(),
5374                verdict: c.verdict(),
5375                required: c.is_required,
5376                run: c.url().and_then(run_of),
5377            })
5378            .collect();
5379        rollup_head = oid;
5380    }
5381    pr.checks = checks;
5382    pr.failing = failing;
5383
5384    Ok(Seen {
5385        pr,
5386        title: raw.title,
5387        failing_urls,
5388        head: raw.head_ref_oid,
5389        rollup_head,
5390        merge_state: raw.merge_state_status,
5391        contexts,
5392        base: raw.base_ref_name,
5393    })
5394}
5395
5396/// The last commit's oid and its checks from one GraphQL response, `None`
5397/// when anything about it cannot be trusted.
5398fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5399    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5400    if v.get("errors").is_some_and(|e| !e.is_null()) {
5401        return None;
5402    }
5403    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5404    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5405    let contexts = commit.pointer("/statusCheckRollup/contexts");
5406    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5407        // No rollup at all: the commit has no checks.
5408        return Some((oid.to_owned(), Vec::new()));
5409    };
5410    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5411        return None;
5412    }
5413    let nodes = contexts.get("nodes")?.as_array()?;
5414    let rollup = nodes
5415        .iter()
5416        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5417        .collect::<Result<Vec<_>, _>>()
5418        .ok()?;
5419    Some((oid.to_owned(), rollup))
5420}
5421
5422/// The pull request's last commit and its checks, in one response. `None`
5423/// when the forge could not be asked.
5424async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5425    let out = gh(
5426        repo,
5427        &[
5428            "api".to_owned(),
5429            "graphql".to_owned(),
5430            "-F".to_owned(),
5431            "owner={owner}".to_owned(),
5432            "-F".to_owned(),
5433            "repo={repo}".to_owned(),
5434            "-F".to_owned(),
5435            format!("number={number}"),
5436            "-f".to_owned(),
5437            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5438             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5439             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5440             ... on CheckRun{name status conclusion detailsUrl \
5441             isRequired(pullRequestNumber:$number)} \
5442             ... on StatusContext{context state targetUrl \
5443             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5444                .to_owned(),
5445        ],
5446    )
5447    .await
5448    .ok()?;
5449    out.0.then_some(out.1)
5450}
5451
5452/// What a fix round did.
5453#[doc(hidden)]
5454#[derive(Debug, PartialEq)]
5455pub enum Fixed {
5456    /// The fixer committed something, and this is the head that was pushed.
5457    Committed {
5458        /// The commit now at the tip of the pushed branch.
5459        head: String,
5460    },
5461    /// The fixer ran and chose to change nothing.
5462    Declined,
5463    /// The fixer could not run, or said nothing usable.
5464    Failed(String),
5465}
5466
5467/// Hand the failures and the comments to the fixer, then commit and push.
5468///
5469/// The fixer works in the winner's own worktree so its commits land on the
5470/// branch the pull request is built from, and it runs with `allow_write` for
5471/// the same reason.
5472#[doc(hidden)]
5473pub async fn fix_round(
5474    state: &mut RunState,
5475    pr: &PrState,
5476    round: usize,
5477    budget: usize,
5478    reason: &str,
5479    logs: &str,
5480) -> Result<Fixed> {
5481    let winner = state
5482        .winner()
5483        .cloned()
5484        .context("landing needs a winning candidate; none is recorded on this run")?;
5485    let roles = state
5486        .config
5487        .resolve_roles()
5488        .context("resolve the roster for the fix round")?;
5489    // Same chain as the review loop (see `crate::fixer`): the configured
5490    // fixers in order, otherwise the winner's own author continuing its own
5491    // conversation - the competition is over, so its context is pure benefit.
5492    let attempts = crate::fixer::attempts(state, &roles, &winner);
5493    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5494
5495    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5496    let mut artifacts = agent::artifacts_dir(&state.dir());
5497    if let Some(request) = state
5498        .merge_repairs
5499        .last()
5500        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5501    {
5502        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5503    }
5504    let prompt = if state.config.cache_dir().is_some() {
5505        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5506    } else {
5507        prompt
5508    };
5509    // Read before the fixer runs: it normally commits for itself, so HEAD has
5510    // already moved by the time it returns and a later read would see no
5511    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5512    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5513    // Each id at most once, forward only: the next one is asked only when the
5514    // call advances, and only the last attempt's result is judged below, so an
5515    // exhausted chain ends as a single failed fixer does.
5516    let mut last = None;
5517    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5518        if owner_requested_stop(state)? {
5519            return Ok(Fixed::Declined);
5520        }
5521        let mut seat = seat_of(state, &seat_key, &spec.id);
5522        let stem = if i == 0 {
5523            format!("land-{round}")
5524        } else {
5525            format!("land-{round}-{}", spec.id)
5526        };
5527        let out = agent::invoke(
5528            &spec,
5529            &mut seat,
5530            &Invocation {
5531                coordination: state.config.coordination.as_ref(),
5532                fetched_base: state.fetched_base.as_ref(),
5533                cwd: &winner.worktree,
5534                prompt: &prompt,
5535                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5536                allow_write: true,
5537                unsandboxed: false,
5538                sessions: state.config.graph.sessions,
5539                artifacts: &artifacts,
5540                stem: &stem,
5541                run: &state.id,
5542                node: "land",
5543                cache_dir: state.config.cache_dir().as_deref(),
5544                attachments: &[],
5545                writable: &[],
5546            },
5547        )
5548        .await;
5549        state.seats.insert(seat.key.clone(), seat);
5550        if let Some(next) = ids.get(i + 1)
5551            && agent::chain_advances(&out)
5552        {
5553            let (class, why) = crate::fixer::failure_of(&out);
5554            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5555            continue;
5556        }
5557        last = Some(out);
5558        break;
5559    }
5560    let out = last.expect("the fixer chain always has an entry");
5561
5562    match out {
5563        Ok(o) if o.quota_exhausted() => {
5564            return Ok(Fixed::Failed(
5565                "rate limited (quota); the fixer could not run".to_owned(),
5566            ));
5567        }
5568        Ok(o) if !o.usable() => {
5569            return Ok(Fixed::Failed(format!(
5570                "the fixer produced nothing usable ({})",
5571                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5572            )));
5573        }
5574        Ok(_) => {}
5575        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5576        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5577    }
5578
5579    if owner_requested_stop(state)? {
5580        return Ok(Fixed::Declined);
5581    }
5582
5583    // An agent that edited files but never committed would otherwise push
5584    // nothing and look like a refusal.
5585    let wait = crate::repo_lock::WaitPolicy::new(
5586        Duration::from_secs(state.config.graph.timeout_resource_wait),
5587        || false,
5588    );
5589    let coordination = crate::repo_lock::SeatLease::operation_with(
5590        &winner.worktree,
5591        state.config.coordination.as_ref(),
5592        &wait,
5593    )
5594    .await?;
5595    match git::rescue_commit(
5596        &winner.worktree,
5597        &format!("magi: land round {round} fixes (uncommitted work)"),
5598    )
5599    .await
5600    {
5601        Ok(r) => state.note_withheld("land", &r.withheld),
5602        Err(e)
5603            if state
5604                .merge_repairs
5605                .last()
5606                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5607        {
5608            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5609        }
5610        Err(_) => {}
5611    }
5612    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5613    drop(coordination);
5614    if after == before {
5615        return Ok(Fixed::Declined);
5616    }
5617
5618    if owner_requested_stop(state)? {
5619        return Ok(Fixed::Declined);
5620    }
5621    crate::pr_head::ensure_owner(state)?;
5622    let remote = state.config.merge.remote.clone();
5623    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5624    let push = if state.pr_head.is_some() {
5625        let expected = state
5626            .pr_pushed_head
5627            .as_deref()
5628            .context("missing public head lease")?;
5629        git::push_as(
5630            &winner.worktree,
5631            &remote,
5632            &winner.branch,
5633            &public_head,
5634            expected,
5635        )
5636        .await?
5637    } else {
5638        git::push(&winner.worktree, &remote, &winner.branch).await?
5639    };
5640    if !push.ok() {
5641        return Ok(Fixed::Failed(format!(
5642            "pushing {} to {remote} failed: {}",
5643            public_head, push.stderr
5644        )));
5645    }
5646    state.event(
5647        "land",
5648        format!("round {round}: pushed a fix to {public_head}"),
5649    );
5650    state.pr_pushed_head = Some(after.clone());
5651    state.save()?;
5652    Ok(Fixed::Committed { head: after })
5653}
5654
5655/// Fetch or create a seat, keeping its conversation across nodes.
5656pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5657    crate::fixer::seat_for(state, key, agent)
5658}
5659
5660/// Recheck the live PR and local worktree before spending the requested fix.
5661/// A moved/closed PR is held, never repaired using obsolete evidence.
5662pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5663    let seen = observe(&state.repo, url).await?;
5664    let winner = state.winner().context("no winning branch to repair")?;
5665    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5666        anyhow::bail!(
5667            "PR is closed or its head changed; review the current head before requesting repair again"
5668        );
5669    }
5670    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5671        || !git::is_clean(&winner.worktree).await?
5672    {
5673        anyhow::bail!(
5674            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5675        );
5676    }
5677    // Always disable any forge-side reservation, including one not recorded
5678    // by this run. A repair request is never merge authorization.
5679    let view = gh(
5680        &state.repo,
5681        &[
5682            "pr".into(),
5683            "view".into(),
5684            url.into(),
5685            "--json".into(),
5686            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5687        ],
5688    )
5689    .await?;
5690    if !view.0 {
5691        bail!("cannot check auto-merge: {}", view.1);
5692    }
5693    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5694    let public = state.pr_branch().unwrap_or(&winner.branch);
5695    if live["state"] != "OPEN"
5696        || live["headRefOid"] != head
5697        || live["headRefName"] != public
5698        || live["baseRefName"] != state.base_branch
5699        || live["isCrossRepository"] != false
5700    {
5701        bail!("PR changed during repair preflight; review its current head explicitly");
5702    }
5703    let auto_merge = live
5704        .get("autoMergeRequest")
5705        .context("cannot read the PR auto-merge reservation; repair held")?;
5706    if !auto_merge.is_null() {
5707        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5708        if !out.0 {
5709            bail!("cannot disable auto-merge; repair held: {}", out.1);
5710        }
5711    }
5712    Ok(seen.pr)
5713}
5714
5715/// What the fixer is told.
5716fn fix_prompt(
5717    state: &RunState,
5718    pr: &PrState,
5719    round: usize,
5720    budget: usize,
5721    reason: &str,
5722    logs: &str,
5723) -> String {
5724    let mut s = format!(
5725        "Your patch is open as a pull request and it is not landing. Land round \
5726         {round} of {budget}.\n\n\
5727         Pull request: {}\n\n\
5728         What is holding it: {reason}\n\n\
5729         # The task\n\n{}\n",
5730        pr.url, state.instruction
5731    );
5732
5733    if pr.failing.is_empty() {
5734        s.push_str("\n# Failing checks\n\n(none)\n");
5735    } else {
5736        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5737        if logs.trim().is_empty() {
5738            s.push_str(
5739                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5740            );
5741        } else {
5742            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5743        }
5744    }
5745
5746    if pr.review_comments.is_empty() {
5747        s.push_str("\n# Review comments\n\n(none)\n");
5748    } else {
5749        s.push_str("\n# Review comments\n");
5750        for c in &pr.review_comments {
5751            let where_ = match (&c.path, c.line) {
5752                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5753                (Some(p), None) => format!(" ({p})"),
5754                _ => String::new(),
5755            };
5756            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5757        }
5758    }
5759
5760    s.push_str(
5761        "\n# Rules\n\n\
5762         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5763            failing test; do not silence a lint with an allow attribute; do not \
5764            stretch a timeout to hide a race. If the check is right, the code is \
5765            wrong.\n\
5766         2. Change nothing the checks and the comments did not raise. A \
5767            drive-by refactor turns a one-line fix into a pull request that \
5768            needs reviewing again.\n\
5769         3. If a comment is wrong, say so with a checkable argument and change \
5770            nothing for it. A declined comment with a reason is a correct \
5771            outcome; a change made to appease a reviewer is not.\n\
5772         4. Commit in this worktree. magi pushes to the pull request's branch \
5773            for you; do not push, merge, or close anything yourself.\n\
5774         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5775         # Output\n\n\
5776         Say what you changed and why, and what you declined and why.",
5777    );
5778
5779    let language = &state.config.graph.language;
5780    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5781        let _ = write!(s, "\n\nWrite all prose in {language}.");
5782    }
5783    // After the language line, so the exception is the last word on it.
5784    s.push_str(&crate::prompt::github_rule(
5785        language,
5786        &state.config.graph.github_language,
5787    ));
5788    if let Some(overlay) = state.config.prompts.overlay("fix") {
5789        let _ = write!(s, "\n\n{overlay}");
5790    }
5791    s.push('\n');
5792    s.push_str(crate::prompt::child_run_ownership());
5793    s
5794}
5795
5796/// Failing log tails, the way the operator collects them by hand:
5797/// `gh run view --log-failed`.
5798async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5799    let mut out = String::new();
5800    for (name, url) in failing.iter().take(MAX_LOGS) {
5801        let args = match (job_of(url), run_of(url)) {
5802            (Some(job), _) => vec![
5803                "run".to_owned(),
5804                "view".to_owned(),
5805                "--log-failed".to_owned(),
5806                "--job".to_owned(),
5807                job,
5808            ],
5809            (None, Some(run)) => vec![
5810                "run".to_owned(),
5811                "view".to_owned(),
5812                run,
5813                "--log-failed".to_owned(),
5814            ],
5815            // Not a GitHub Actions check - an external status has no log here.
5816            (None, None) => continue,
5817        };
5818        let (ok, body) = match gh(repo, &args).await {
5819            Ok(v) => v,
5820            Err(e) => (false, format!("{e:#}")),
5821        };
5822        if !ok && body.trim().is_empty() {
5823            continue;
5824        }
5825        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5826    }
5827    out
5828}
5829
5830/// Job id out of a check's `detailsUrl`
5831/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5832fn job_of(details_url: &str) -> Option<String> {
5833    let after = details_url.split("/job/").nth(1)?;
5834    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5835    (!id.is_empty()).then_some(id)
5836}
5837
5838/// Workflow run id out of a check's `detailsUrl`.
5839pub(crate) fn run_of(details_url: &str) -> Option<String> {
5840    let after = details_url.split("/actions/runs/").nth(1)?;
5841    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5842    (!id.is_empty()).then_some(id)
5843}
5844
5845/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5846/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5847/// it to that.
5848fn stop_comment(run_id: &str, why: &str) -> String {
5849    format!(
5850        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5851         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5852    )
5853}
5854
5855/// Leave the pull request open, say why on it, and mark the run blocked.
5856///
5857/// The comment is what makes an unattended stop actionable: the operator wakes
5858/// up to a pull request that explains itself rather than to a silent queue.
5859async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5860    let body = stop_comment(&state.id, why);
5861    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5862    if !body.contains(MARKER) {
5863        body = format!("{MARKER}\n{body}");
5864    }
5865    let posted = gh(
5866        repo,
5867        &[
5868            "pr".to_owned(),
5869            "comment".to_owned(),
5870            pr.number.to_string(),
5871            "--body".to_owned(),
5872            body,
5873        ],
5874    )
5875    .await;
5876    match posted {
5877        Ok((true, _)) => {}
5878        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5879        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5880    }
5881    state.status = RunStatus::Blocked;
5882    state.merge = Some(MergeOutcome {
5883        mode: MergeMode::Pr,
5884        ok: false,
5885        detail: why.to_owned(),
5886        empty: false,
5887    });
5888    state.event("land", format!("stopped: {why}"));
5889    state.save()?;
5890    Ok(())
5891}
5892
5893/// Run `gh` in `repo`, returning success and the combined output.
5894///
5895/// Combined because `gh` reports a refused merge on stderr and the pull request
5896/// json on stdout, and both are evidence.
5897///
5898/// `GH_REPO` is stripped from the child's environment: every call site here
5899/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5900/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5901/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5902/// `GH_REPO` the operator happens to have exported for an unrelated script
5903/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5904/// below) to a different repository than the one actually on disk - which
5905/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5906/// could be made to agree with whatever repository a forged `--merged` URL
5907/// claims, defeating it entirely.
5908pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5909    let out = tokio::process::Command::new("gh")
5910        .args(args)
5911        .current_dir(cwd)
5912        .env_remove("GH_REPO")
5913        .quiet()
5914        .stdin(std::process::Stdio::null())
5915        .output()
5916        .await
5917        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5918    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
5919    let err = String::from_utf8_lossy(&out.stderr);
5920    if body.trim().is_empty() {
5921        body = err.into_owned();
5922    } else if !err.trim().is_empty() {
5923        body.push_str(&err);
5924    }
5925    Ok((out.status.success(), body.trim().to_owned()))
5926}
5927
5928/// Verdict of one entry in the status rollup.
5929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5930pub(crate) enum Verdict {
5931    Pass,
5932    Fail,
5933    Pending,
5934    Unknown,
5935}
5936
5937impl Verdict {
5938    /// Short operator-facing check result.
5939    pub(crate) fn label(self) -> &'static str {
5940        match self {
5941            Self::Pass => "passed",
5942            Self::Fail => "failed",
5943            Self::Pending => "pending",
5944            Self::Unknown => "unknown",
5945        }
5946    }
5947}
5948
5949#[derive(Debug, Deserialize)]
5950#[serde(rename_all = "camelCase")]
5951struct GhPr {
5952    #[serde(default)]
5953    url: String,
5954    #[serde(default)]
5955    number: u64,
5956    #[serde(default)]
5957    state: String,
5958    #[serde(default)]
5959    title: String,
5960    #[serde(default)]
5961    status_check_rollup: Vec<GhCheck>,
5962    /// GitHub's own verdict on whether the pull request can be merged.
5963    ///
5964    /// Worth asking for because it is the only place the *required* check set
5965    /// is applied: the rollup lists every check equally, so a repository that
5966    /// deliberately does not require `coverage` still looks red here. See
5967    /// [`Blocking`].
5968    #[serde(default)]
5969    merge_state_status: String,
5970    /// The commit the pull request currently points at. Compared with the
5971    /// commit a fix round pushed, it is how the loop knows the forge has moved
5972    /// on and the rollup belongs to the new head.
5973    #[serde(default)]
5974    head_ref_oid: String,
5975    #[serde(default)]
5976    base_ref_name: String,
5977    #[serde(default)]
5978    reviews: Vec<GhReview>,
5979    #[serde(default)]
5980    comments: Vec<GhComment>,
5981}
5982
5983/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
5984/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
5985/// status API, which is how CodeRabbit reports - has `context`/`state` and no
5986/// conclusion at all.
5987#[derive(Debug, Deserialize)]
5988#[serde(rename_all = "camelCase")]
5989struct GhCheck {
5990    #[serde(default)]
5991    name: Option<String>,
5992    #[serde(default)]
5993    context: Option<String>,
5994    #[serde(default)]
5995    status: Option<String>,
5996    #[serde(default)]
5997    conclusion: Option<String>,
5998    #[serde(default)]
5999    state: Option<String>,
6000    #[serde(default)]
6001    details_url: Option<String>,
6002    #[serde(default)]
6003    target_url: Option<String>,
6004    /// Whether the base branch requires this check. Only the GraphQL node
6005    /// carries it; `None` is "not read", never "not required".
6006    #[serde(default)]
6007    is_required: Option<bool>,
6008}
6009
6010impl GhCheck {
6011    /// Name to show a human and hand to the fixer.
6012    fn label(&self) -> String {
6013        self.name
6014            .clone()
6015            .or_else(|| self.context.clone())
6016            .unwrap_or_else(|| "(unnamed check)".to_owned())
6017    }
6018
6019    /// Where this check's logs live, when it has any.
6020    fn url(&self) -> Option<&str> {
6021        self.details_url
6022            .as_deref()
6023            .or(self.target_url.as_deref())
6024            .filter(|u| !u.is_empty())
6025    }
6026
6027    /// Did it pass?
6028    ///
6029    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6030    /// skips release and bot pull requests by design, and a skip that blocked
6031    /// landing would block exactly the pull requests that need no review.
6032    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6033    /// merging over one is merging over a check that never ran.
6034    fn verdict(&self) -> Verdict {
6035        if let Some(status) = self.status.as_deref() {
6036            if !status.eq_ignore_ascii_case("COMPLETED") {
6037                return Verdict::Pending;
6038            }
6039        }
6040        let outcome = self
6041            .conclusion
6042            .as_deref()
6043            .or(self.state.as_deref())
6044            .unwrap_or("");
6045        match outcome.to_ascii_uppercase().as_str() {
6046            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6047            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6048            | "ACTION_REQUIRED" => Verdict::Fail,
6049            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6050                Verdict::Pending
6051            }
6052            _ => Verdict::Unknown,
6053        }
6054    }
6055}
6056
6057#[derive(Debug, Deserialize)]
6058struct GhAuthor {
6059    #[serde(default)]
6060    login: String,
6061}
6062
6063#[derive(Debug, Deserialize)]
6064struct GhReview {
6065    #[serde(default)]
6066    author: GhAuthor,
6067    #[serde(default)]
6068    body: String,
6069}
6070
6071#[derive(Debug, Deserialize)]
6072struct GhComment {
6073    #[serde(default)]
6074    author: GhAuthor,
6075    #[serde(default)]
6076    body: String,
6077}
6078
6079#[derive(Debug, Deserialize)]
6080struct GhUser {
6081    #[serde(default)]
6082    login: String,
6083}
6084
6085#[derive(Debug, Deserialize)]
6086struct GhInline {
6087    #[serde(default)]
6088    user: GhUser,
6089    #[serde(default)]
6090    path: Option<String>,
6091    #[serde(default)]
6092    line: Option<u64>,
6093    #[serde(default)]
6094    body: String,
6095}
6096
6097impl Default for GhAuthor {
6098    fn default() -> Self {
6099        Self {
6100            login: "(unknown)".to_owned(),
6101        }
6102    }
6103}
6104
6105impl Default for GhUser {
6106    fn default() -> Self {
6107        Self {
6108            login: "(unknown)".to_owned(),
6109        }
6110    }
6111}
6112
6113#[cfg(test)]
6114pub(crate) mod tests {
6115    use super::*;
6116    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6117
6118    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6119        format!(
6120            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6121        )
6122    }
6123
6124    #[test]
6125    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6126        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6127        assert_eq!(
6128            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6129            Ok(())
6130        );
6131        for (json, why) in [
6132            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6133            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6134            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6135            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6136            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6137            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6138        ] {
6139            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6140                .unwrap_err()
6141                .why;
6142            assert!(err.contains(why), "{json}: {err}");
6143        }
6144        // A head that moved on past what was verified is left alone.
6145        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6146        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6147        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6148        assert!(
6149            !closable(
6150                &head_json("x", "main", "OPEN", false),
6151                "magi/27b2/A",
6152                "main",
6153                &[]
6154            )
6155            .unwrap_err()
6156            .retry
6157        );
6158        assert!(is_forge_url("https://github.com/o/r.git"));
6159        assert!(is_forge_url("git@github.com:o/r.git"));
6160        assert!(!is_forge_url("/tmp/origin.git"));
6161        assert!(!is_forge_url("C:\\work\\origin.git"));
6162        assert!(!is_forge_url("file:///tmp/origin.git"));
6163        assert!(forge_unavailable(
6164            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6165        ));
6166        assert!(!forge_unavailable(
6167            "gh pr list failed: error connecting to api.github.com"
6168        ));
6169        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6170        // A record that does not say whether it is a fork is not trusted.
6171        assert!(
6172            closable(
6173                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6174                "b",
6175                "main",
6176                &["aaa".to_owned()]
6177            )
6178            .is_err()
6179        );
6180    }
6181
6182    #[test]
6183    fn the_close_comment_names_the_commit_on_the_base() {
6184        let e = crate::already::Evidence {
6185            proof: crate::already::Proof::PatchId,
6186            tip: "1234567890".to_owned(),
6187            commits: vec!["0e368de0000".to_owned()],
6188        };
6189        let c = superseded_comment("main", &e);
6190        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6191    }
6192
6193    /// 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.
6194    const GREEN_OPEN: &str = r####"{
6195  "url": "https://github.com/yukimemi/magi/pull/10",
6196  "number": 10,
6197  "state": "OPEN",
6198  "mergeStateStatus": "CLEAN",
6199  "statusCheckRollup": [
6200    {
6201      "__typename": "CheckRun",
6202      "conclusion": "SKIPPED",
6203      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6204      "name": "review",
6205      "status": "COMPLETED",
6206      "workflowName": "claude-review"
6207    },
6208    {
6209      "__typename": "CheckRun",
6210      "conclusion": "SUCCESS",
6211      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6212      "name": "check (ubuntu-latest)",
6213      "status": "COMPLETED",
6214      "workflowName": "CI"
6215    },
6216    {
6217      "__typename": "CheckRun",
6218      "conclusion": "SUCCESS",
6219      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6220      "name": "rustfmt",
6221      "status": "COMPLETED",
6222      "workflowName": "CI"
6223    },
6224    {
6225      "__typename": "StatusContext",
6226      "context": "CodeRabbit",
6227      "state": "SUCCESS",
6228      "targetUrl": ""
6229    }
6230  ],
6231  "reviews": [],
6232  "comments": [
6233    {
6234      "author": {
6235        "login": "coderabbitai"
6236      },
6237      "authorAssociation": "NONE",
6238      "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"
6239    }
6240  ]
6241}"####;
6242
6243    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6244    const RED_OPEN: &str = r####"{
6245  "url": "https://github.com/yukimemi/magi/pull/9",
6246  "number": 9,
6247  "state": "OPEN",
6248  "mergeStateStatus": "UNSTABLE",
6249  "statusCheckRollup": [
6250    {
6251      "__typename": "CheckRun",
6252      "conclusion": "SUCCESS",
6253      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6254      "name": "check (ubuntu-latest)",
6255      "status": "COMPLETED",
6256      "workflowName": "CI"
6257    },
6258    {
6259      "__typename": "CheckRun",
6260      "conclusion": "SUCCESS",
6261      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6262      "name": "rustfmt",
6263      "status": "COMPLETED",
6264      "workflowName": "CI"
6265    },
6266    {
6267      "__typename": "CheckRun",
6268      "conclusion": "FAILURE",
6269      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6270      "name": "editorconfig",
6271      "status": "COMPLETED",
6272      "workflowName": "CI"
6273    },
6274    {
6275      "__typename": "StatusContext",
6276      "context": "CodeRabbit",
6277      "state": "SUCCESS",
6278      "targetUrl": ""
6279    }
6280  ],
6281  "reviews": [],
6282  "comments": [
6283    {
6284      "author": {
6285        "login": "coderabbitai"
6286      },
6287      "authorAssociation": "NONE",
6288      "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"
6289    }
6290  ]
6291}"####;
6292
6293    /// 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.
6294    const PENDING_OPEN: &str = r####"{
6295  "url": "https://github.com/yukimemi/magi/pull/9",
6296  "number": 9,
6297  "state": "OPEN",
6298  "statusCheckRollup": [
6299    {
6300      "__typename": "CheckRun",
6301      "conclusion": "SUCCESS",
6302      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6303      "name": "check (ubuntu-latest)",
6304      "status": "COMPLETED",
6305      "workflowName": "CI"
6306    },
6307    {
6308      "__typename": "CheckRun",
6309      "conclusion": "SUCCESS",
6310      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6311      "name": "rustfmt",
6312      "status": "COMPLETED",
6313      "workflowName": "CI"
6314    },
6315    {
6316      "__typename": "CheckRun",
6317      "conclusion": null,
6318      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6319      "name": "editorconfig",
6320      "status": "IN_PROGRESS",
6321      "workflowName": "CI"
6322    },
6323    {
6324      "__typename": "StatusContext",
6325      "context": "CodeRabbit",
6326      "state": "SUCCESS",
6327      "targetUrl": ""
6328    }
6329  ],
6330  "reviews": [],
6331  "comments": []
6332}"####;
6333
6334    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6335    const MERGED: &str = r####"{
6336  "url": "https://github.com/yukimemi/magi/pull/16",
6337  "number": 16,
6338  "state": "MERGED",
6339  "statusCheckRollup": [
6340    {
6341      "__typename": "CheckRun",
6342      "conclusion": "SUCCESS",
6343      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6344      "name": "check (ubuntu-latest)",
6345      "status": "COMPLETED",
6346      "workflowName": "CI"
6347    },
6348    {
6349      "__typename": "CheckRun",
6350      "conclusion": "SUCCESS",
6351      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6352      "name": "review",
6353      "status": "COMPLETED",
6354      "workflowName": "claude-review"
6355    }
6356  ],
6357  "reviews": [],
6358  "comments": []
6359}"####;
6360
6361    /// 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.
6362    const REVIEWED_OPEN: &str = r####"{
6363  "url": "https://github.com/yukimemi/magi/pull/12",
6364  "number": 12,
6365  "state": "OPEN",
6366  "statusCheckRollup": [
6367    {
6368      "__typename": "CheckRun",
6369      "conclusion": "SUCCESS",
6370      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6371      "name": "check (ubuntu-latest)",
6372      "status": "COMPLETED",
6373      "workflowName": "CI"
6374    },
6375    {
6376      "__typename": "CheckRun",
6377      "conclusion": "SUCCESS",
6378      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6379      "name": "review",
6380      "status": "COMPLETED",
6381      "workflowName": "claude-review"
6382    }
6383  ],
6384  "reviews": [
6385    {
6386      "author": {
6387        "login": "claude"
6388      },
6389      "state": "COMMENTED",
6390      "body": ""
6391    }
6392  ],
6393  "comments": [
6394    {
6395      "author": {
6396        "login": "coderabbitai"
6397      },
6398      "authorAssociation": "NONE",
6399      "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"
6400    },
6401    {
6402      "author": {
6403        "login": "claude"
6404      },
6405      "authorAssociation": "NONE",
6406      "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"
6407    }
6408  ]
6409}"####;
6410
6411    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6412    const INLINE: &str = r####"[
6413  {
6414    "user": {
6415      "login": "claude[bot]"
6416    },
6417    "path": "src/graph.rs",
6418    "line": 231,
6419    "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"
6420  }
6421]"####;
6422
6423    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6424    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6425<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6426
6427> [!IMPORTANT]
6428> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6429> 
6430> This repository does not receive automatic reviews because it has fewer than 10 stars.
6431> 
6432> <details>
6433> <summary>⚙️ Run configuration</summary>
6434> 
6435> **Configuration used**: defaults
6436> 
6437> **Review profile**: CHILL
6438> 
6439> **Plan**: Team
6440> 
6441> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6442> 
6443> </details>
6444
6445<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6446
6447<!-- tips_start -->
6448
6449---
6450
6451Thanks 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.
6452
6453<details>
6454<summary>❤️ Share</summary>
6455
6456- [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"####;
6457
6458    /// 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.
6459    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6460
6461---
6462### Reviewing PR #16
6463
6464- [x] Read AGENTS.md conventions
6465- [x] Review `src/daemon.rs` changes
6466- [x] Review `src/main.rs` changes (new `doctor` reporting)
6467- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6468- [x] Check test coverage for new behavior
6469- [x] Run verification commands (blocked — see note)
6470- [x] Post findings"####;
6471
6472    /// The same job's real comment on pull request #12 once it had something to say.
6473    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6474
6475---
6476### Review: `magi review <branch>` — cheap-half-only graph
6477
6478Read 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.
6479
6480**Correctness**
6481
6482- 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"####;
6483
6484    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6485        PrState {
6486            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6487            number: 16,
6488            state: PrLifecycle::Open,
6489            checks,
6490            // These tests are about red-means-fix, so a red here is one the
6491            // forge gates on. Without saying so they would assert the new
6492            // "merge past a check nobody requires" path by accident.
6493            blocking: if matches!(checks, Checks::Red) {
6494                Blocking::Yes
6495            } else {
6496                Blocking::No
6497            },
6498            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6499            review_comments: (0..comments)
6500                .map(|i| ReviewComment {
6501                    author: "coderabbitai".to_owned(),
6502                    path: Some("src/graph.rs".to_owned()),
6503                    line: Some(231),
6504                    body: format!("finding {i}"),
6505                })
6506                .collect(),
6507        }
6508    }
6509
6510    #[test]
6511    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6512        use CiExpectation::{Absent, Expected};
6513        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6514            let p = pr(checks, &["x"], 0);
6515            for waited in [Duration::ZERO, CHECKS_GRACE] {
6516                assert_eq!(
6517                    decide_with(&p, 0, 4, waited, Expected),
6518                    decide(&p, 0, 4, waited)
6519                );
6520            }
6521        }
6522        // Nothing will ever report: no wait, no give-up, no fix round.
6523        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6524            let p = pr(checks, &["x"], 0);
6525            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6526            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6527        }
6528        // Third-party statuses never change the review decision, even at budget.
6529        for comments in [0, 1] {
6530            for round in [0, 4] {
6531                let absent = decide_with(
6532                    &pr(Checks::Unknown, &[], comments),
6533                    round,
6534                    4,
6535                    CHECKS_GRACE,
6536                    Absent,
6537                );
6538                for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6539                    assert_eq!(
6540                        decide_with(
6541                            &pr(checks, &["third-party"], comments),
6542                            round,
6543                            4,
6544                            Duration::ZERO,
6545                            Absent
6546                        ),
6547                        absent
6548                    );
6549                }
6550                if comments != 0 {
6551                    assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6552                    assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6553                }
6554            }
6555        }
6556        let empty = pr(Checks::Unknown, &[], 0);
6557        for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6558            assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6559        }
6560        assert_eq!(
6561            decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6562            Step::Wait
6563        );
6564        assert_eq!(
6565            decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6566            Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6567        );
6568        // A conflict is not a check: it still wants the rebase.
6569        let mut p = pr(Checks::Unknown, &[], 0);
6570        p.blocking = Blocking::Conflict;
6571        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6572        // And a pull request that left our hands is still done.
6573        p.state = PrLifecycle::Merged;
6574        assert_eq!(
6575            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6576            Step::Done { merged: true }
6577        );
6578    }
6579
6580    #[test]
6581    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6582        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6583        assert_eq!(state.number, 10);
6584        assert_eq!(state.state, PrLifecycle::Open);
6585        assert_eq!(state.checks, Checks::Green);
6586        assert!(state.failing.is_empty());
6587        assert!(
6588            state.review_comments.is_empty(),
6589            "the only comment is CodeRabbit's trigger notice: {:?}",
6590            state.review_comments
6591        );
6592        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6593    }
6594
6595    #[test]
6596    fn a_failing_check_parses_as_red_and_is_named() {
6597        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6598        assert_eq!(state.checks, Checks::Red);
6599        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6600        // The captured payload says `UNSTABLE` - mergeable, with a check
6601        // nobody requires red - which is exactly the shape that had to be
6602        // merged by hand. Asserted separately, in
6603        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6604        // test is about is that a red check is *named*, so the reason a fixer
6605        // is handed says which one; so it asks the blocking question here.
6606        let mut blocking = state.clone();
6607        blocking.blocking = Blocking::Yes;
6608        match decide(&blocking, 0, 4, Duration::ZERO) {
6609            Step::Fix { reason } => {
6610                assert!(reason.contains("editorconfig"), "reason: {reason}");
6611                assert!(reason.contains("failing"), "reason: {reason}");
6612            }
6613            other => panic!("expected a fix round, got {other:?}"),
6614        }
6615    }
6616
6617    #[test]
6618    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6619        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6620        assert_eq!(state.checks, Checks::Pending);
6621        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6622    }
6623
6624    #[test]
6625    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6626        let state = parse_pr(MERGED).expect("merged fixture parses");
6627        assert_eq!(state.state, PrLifecycle::Merged);
6628        assert_eq!(
6629            decide(&state, 0, 4, Duration::ZERO),
6630            Step::Done { merged: true }
6631        );
6632    }
6633
6634    #[test]
6635    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6636        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6637        assert_eq!(state.checks, Checks::Green);
6638        let authors: Vec<&str> = state
6639            .review_comments
6640            .iter()
6641            .map(|c| c.author.as_str())
6642            .collect();
6643        assert_eq!(
6644            authors,
6645            vec!["claude"],
6646            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6647        );
6648        match decide(&state, 0, 4, Duration::ZERO) {
6649            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6650            other => panic!("expected a fix round, got {other:?}"),
6651        }
6652    }
6653
6654    #[test]
6655    fn inline_review_comments_keep_their_file_and_line() {
6656        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6657        assert_eq!(comments.len(), 1);
6658        assert_eq!(comments[0].author, "claude[bot]");
6659        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6660        assert_eq!(comments[0].line, Some(231));
6661        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6662    }
6663
6664    #[test]
6665    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6666        assert!(
6667            is_noise(CODERABBIT_TRIGGER),
6668            "CodeRabbit's trigger notice declares itself not a review"
6669        );
6670        assert!(
6671            is_noise(CLAUDE_CHECKLIST),
6672            "a progress checklist asks for nothing"
6673        );
6674        assert!(
6675            !is_noise(CLAUDE_FINDING),
6676            "a review that names a bug is input, not noise"
6677        );
6678
6679        let mut clean = pr(Checks::Green, &[], 0);
6680        clean.review_comments.push(ReviewComment {
6681            author: "coderabbitai".to_owned(),
6682            path: None,
6683            line: None,
6684            body: CODERABBIT_TRIGGER.to_owned(),
6685        });
6686        clean.review_comments.retain(|c| !is_noise(&c.body));
6687        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6688
6689        let mut found = pr(Checks::Green, &[], 0);
6690        found.review_comments.push(ReviewComment {
6691            author: "claude".to_owned(),
6692            path: None,
6693            line: None,
6694            body: CLAUDE_FINDING.to_owned(),
6695        });
6696        found.review_comments.retain(|c| !is_noise(&c.body));
6697        assert!(matches!(
6698            decide(&found, 0, 4, Duration::ZERO),
6699            Step::Fix { .. }
6700        ));
6701    }
6702
6703    #[test]
6704    fn the_policy_table_holds_for_every_combination_that_matters() {
6705        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6706            (
6707                "pending checks are waited for, even on the last round",
6708                pr(Checks::Pending, &[], 0),
6709                4,
6710                4,
6711                Duration::ZERO,
6712                Step::Wait,
6713            ),
6714            (
6715                "red checks are fixed",
6716                pr(Checks::Red, &["editorconfig"], 0),
6717                0,
6718                4,
6719                Duration::ZERO,
6720                Step::Fix {
6721                    reason: "1 check(s) failing: editorconfig".to_owned(),
6722                },
6723            ),
6724            (
6725                "green with comments is fixed, not merged",
6726                pr(Checks::Green, &[], 2),
6727                1,
6728                4,
6729                Duration::ZERO,
6730                Step::Fix {
6731                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6732                        .to_owned(),
6733                },
6734            ),
6735            (
6736                "green and clean merges",
6737                pr(Checks::Green, &[], 0),
6738                3,
6739                4,
6740                Duration::ZERO,
6741                Step::Merge,
6742            ),
6743            (
6744                "an unreadable rollup is waited on while the grace lasts",
6745                pr(Checks::Unknown, &[], 0),
6746                0,
6747                4,
6748                Duration::ZERO,
6749                Step::Wait,
6750            ),
6751            (
6752                "an unreadable rollup is never merged once the grace is spent",
6753                pr(Checks::Unknown, &[], 0),
6754                0,
6755                4,
6756                CHECKS_GRACE,
6757                Step::GiveUp {
6758                    reason: "no check status is readable on the pull request after 3 minute(s); \
6759                             refusing to merge on a guess"
6760                        .to_owned(),
6761                },
6762            ),
6763        ];
6764        for (what, state, round, budget, waited, want) in cases {
6765            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6766        }
6767    }
6768
6769    #[test]
6770    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6771        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6772        // requested but never parsed is the kind of thing that looks wired up
6773        // and answers `Unsaid` forever.
6774        let green = parse_pr(GREEN_OPEN).expect("parse");
6775        assert_eq!(green.blocking, Blocking::No);
6776        let red = parse_pr(RED_OPEN).expect("parse");
6777        assert_eq!(
6778            red.blocking,
6779            Blocking::No,
6780            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6781        );
6782        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6783        // A payload from an older `gh` has no such field at all.
6784        let quiet =
6785            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6786        assert_eq!(quiet.blocking, Blocking::Unsaid);
6787    }
6788
6789    #[test]
6790    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6791        // Pull request 37's only red check was `editorconfig`, failing
6792        // because the action could not fetch its own binary after
6793        // editorconfig-checker v4 renamed its release assets. The repository
6794        // does not require it. magi answered by asking a fixer to repair a
6795        // change that was fine, and the pull request had to be merged by hand.
6796        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6797        nonblocking.blocking = Blocking::No;
6798        assert_eq!(
6799            decide(&nonblocking, 0, 4, Duration::ZERO),
6800            Step::Merge,
6801            "the forge says nothing is in the way, so nothing is"
6802        );
6803
6804        // The same red, gated on: that is a fix round, as before.
6805        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6806        blocking.blocking = Blocking::Yes;
6807        assert!(matches!(
6808            decide(&blocking, 0, 4, Duration::ZERO),
6809            Step::Fix { .. }
6810        ));
6811
6812        // A review comment still outranks green-enough: a non-required red
6813        // must not become a way to merge past an unanswered reviewer.
6814        let mut commented = pr(Checks::Red, &["coverage"], 1);
6815        commented.blocking = Blocking::No;
6816        assert!(matches!(
6817            decide(&commented, 0, 4, Duration::ZERO),
6818            Step::Fix { .. }
6819        ));
6820
6821        // And silence from the forge is not consent.
6822        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6823        unsaid.blocking = Blocking::Unsaid;
6824        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6825        assert!(matches!(
6826            decide(&unsaid, 0, 4, CHECKS_GRACE),
6827            Step::Fix { .. }
6828        ));
6829    }
6830
6831    #[test]
6832    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6833        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6834        red.blocking = Blocking::No;
6835        assert_eq!(
6836            decide(&red, 0, 4, Duration::ZERO),
6837            Step::Merge,
6838            "announcing must not change the decision"
6839        );
6840        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6841        assert!(said.contains("yukimemi/magi"), "{said}");
6842        assert!(said.contains("#16"), "{said}");
6843        assert!(
6844            said.contains("https://github.com/yukimemi/magi/pull/16"),
6845            "{said}"
6846        );
6847        assert!(
6848            said.contains("test (windows-latest)") && said.contains("coverage"),
6849            "{said}"
6850        );
6851
6852        // `failing` can be left over on a green observation; only `checks` counts.
6853        let green = pr(Checks::Green, &["stale"], 0);
6854        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6855    }
6856
6857    #[test]
6858    fn red_merge_notices_omit_empty_check_details() {
6859        for checks in [vec![], vec!["", "  "]] {
6860            let red = pr(Checks::Red, &checks, 0);
6861            let summary = red_merge_summary("owner/repo", &red).unwrap();
6862            let notice = crate::notices::merged_red("run-1", &summary);
6863            assert_eq!(
6864                notice.message,
6865                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6866            );
6867            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6868        }
6869        let red = pr(Checks::Red, &["", " test ", " "], 0);
6870        let summary = red_merge_summary("owner/repo", &red).unwrap();
6871        assert!(summary.contains("with red checks: test ("), "{summary}");
6872    }
6873
6874    #[test]
6875    fn the_repo_label_comes_from_the_pull_request_url() {
6876        let p = Path::new("/tmp/checkout");
6877        assert_eq!(
6878            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6879            "yukimemi/magi"
6880        );
6881        assert_eq!(repo_label(p, "not a url"), "checkout");
6882    }
6883
6884    #[test]
6885    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6886        // Pull requests 35 and 37 were both rebased by hand: a competition
6887        // that runs for two hours against a repository merging pull requests
6888        // all day conflicts on the way in, and that is arithmetic rather
6889        // than a defect in the change.
6890        let mut conflicted = pr(Checks::Green, &[], 0);
6891        conflicted.blocking = Blocking::Conflict;
6892        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6893
6894        // Decided before the checks, and even with the rounds spent: every
6895        // check on a branch that cannot land is an answer about a state that
6896        // cannot land, and a conflict is not the change's fault.
6897        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6898        red.blocking = Blocking::Conflict;
6899        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6900
6901        // The lifecycle still wins over everything, conflict included.
6902        let mut merged = pr(Checks::Red, &[], 0);
6903        merged.blocking = Blocking::Conflict;
6904        merged.state = PrLifecycle::Merged;
6905        assert_eq!(
6906            decide(&merged, 0, 4, Duration::ZERO),
6907            Step::Done { merged: true }
6908        );
6909    }
6910
6911    #[test]
6912    fn the_forge_verdict_is_read_off_merge_state_status() {
6913        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6914        // matters: mergeable, with a non-required check red or still running.
6915        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6916            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6917            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6918        }
6919        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
6920        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
6921        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
6922        // An older `gh`, or a token without the scope, says nothing - and
6923        // refusing to guess is the rule everywhere else in this module.
6924        for quiet in ["", "UNKNOWN"] {
6925            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
6926            assert!(Blocking::of(quiet).stops_a_merge());
6927        }
6928    }
6929
6930    #[test]
6931    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
6932        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
6933        // The exact stderr from run ec12, in a jj-colocated repository.
6934        let jj = "could not determine current branch: failed to run git: not on any branch";
6935
6936        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
6937            .expect("the forge says merged, so it merged");
6938        assert!(landed.ok);
6939        assert!(
6940            landed.detail.contains("but the pull request is merged"),
6941            "the record must not read as a clean success: {}",
6942            landed.detail
6943        );
6944        assert!(
6945            landed.detail.contains("not on any branch"),
6946            "and it must keep what the command actually said: {}",
6947            landed.detail
6948        );
6949
6950        // A pull request still open means the merge really failed.
6951        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
6952        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
6953        // And an unreadable answer is not evidence of success.
6954        assert!(merged_after_all(&argv, jj, None).is_none());
6955    }
6956
6957    #[test]
6958    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
6959        let mut state = pr(Checks::Red, &["editorconfig"], 3);
6960        state.state = PrLifecycle::Closed;
6961        assert_eq!(
6962            decide(&state, 0, 4, Duration::ZERO),
6963            Step::Done { merged: false },
6964            "a human closing the pull request ends the loop, whatever CI says"
6965        );
6966    }
6967
6968    #[test]
6969    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
6970        let red = decide(
6971            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
6972            4,
6973            4,
6974            Duration::ZERO,
6975        );
6976        match red {
6977            Step::GiveUp { reason } => {
6978                assert!(reason.contains("editorconfig"), "reason: {reason}");
6979                assert!(reason.contains("test (macos)"), "reason: {reason}");
6980                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
6981            }
6982            other => panic!("expected a give-up, got {other:?}"),
6983        }
6984
6985        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
6986        match commented {
6987            Step::GiveUp { reason } => {
6988                assert!(reason.contains("unresolved"), "reason: {reason}");
6989                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
6990            }
6991            other => panic!("expected a give-up, got {other:?}"),
6992        }
6993    }
6994
6995    #[test]
6996    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
6997        let candidate_commit = "magi: candidate A (uncommitted work)";
6998        let subject = merge_subject(candidate_commit, "add retries to the uploader");
6999        let argv = merge_argv(16, &subject);
7000
7001        assert!(argv.contains(&"--squash".to_owned()));
7002        assert!(argv.contains(&"--delete-branch".to_owned()));
7003        assert!(argv.contains(&"--subject".to_owned()));
7004        assert_eq!(
7005            argv.last().map(String::as_str),
7006            Some("add retries to the uploader"),
7007            "the subject must not be the candidate commit message"
7008        );
7009        assert_ne!(subject, candidate_commit);
7010    }
7011
7012    #[test]
7013    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7014        assert_eq!(
7015            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7016            "feat: a queue, an unattended loop, and a phone UI"
7017        );
7018        assert_eq!(
7019            merge_subject("", "# port the retry logic\n\ndetails"),
7020            "port the retry logic",
7021            "an empty title falls back to the task's first line, heading marks stripped"
7022        );
7023    }
7024
7025    #[test]
7026    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7027        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7028        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7029        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7030        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7031        assert_eq!(run_of(""), None);
7032    }
7033
7034    #[test]
7035    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7036        let mut out = Vec::new();
7037        push_if_outstanding(
7038            &mut out,
7039            ReviewComment {
7040                author: "yukimemi".to_owned(),
7041                path: None,
7042                line: None,
7043                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7044            },
7045        );
7046        assert!(out.is_empty());
7047    }
7048
7049    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7050    /// falls back to the repository - which keeps these tests free of a
7051    /// worktree, a `git` invocation and a network.
7052    fn run_state() -> RunState {
7053        let mut state = RunState::new(
7054            std::path::PathBuf::from("/repo/magi"),
7055            "main".to_owned(),
7056            "abcdef1234".to_owned(),
7057            "add retries to the uploader".to_owned(),
7058            crate::config::Config::default(),
7059        );
7060        // Tests run in parallel against one persistent home and the ids
7061        // `RunState::new` draws from the clock can repeat, so two tests would
7062        // share a run's questions. The home also outlives the process, so the
7063        // counter alone would reuse an earlier run's ids.
7064        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7065        let nanos = std::time::SystemTime::now()
7066            .duration_since(std::time::UNIX_EPOCH)
7067            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7068        state.id = format!(
7069            "20261004-{nanos:06}-{:04x}",
7070            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7071        );
7072        state
7073    }
7074
7075    fn green_pr() -> PrState {
7076        PrState {
7077            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7078            number: 42,
7079            state: PrLifecycle::Open,
7080            checks: Checks::Green,
7081            // The forge sees nothing in the way unless a test says otherwise.
7082            blocking: Blocking::No,
7083            failing: Vec::new(),
7084            review_comments: vec![ReviewComment {
7085                author: "coderabbitai".to_owned(),
7086                path: Some("src/land.rs".to_owned()),
7087                line: Some(212),
7088                body: "this branch never checks the exit code".to_owned(),
7089            }],
7090        }
7091    }
7092
7093    #[test]
7094    fn land_fixer_defers_review_and_verification_without_a_cache() {
7095        let state = run_state();
7096        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7097        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7098        assert!(p.contains("Commit in this worktree"), "{p}");
7099    }
7100
7101    #[test]
7102    fn github_facing_land_text_is_english_whatever_the_language() {
7103        let mut state = run_state();
7104        state.config.graph.language = "ja".to_owned();
7105        let comment = stop_comment(&state.id, "checks are still red");
7106        assert!(crate::github_text::check("", &comment).is_empty());
7107        assert!(comment.is_ascii(), "{comment}");
7108        assert!(comment.starts_with(MARKER));
7109
7110        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7111        let ja_at = p.find("Write all prose in ja").unwrap();
7112        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7113        assert!(ja_at < rule_at, "{p}");
7114        assert!(p.contains("stays in Japanese"), "{p}");
7115
7116        state.config.graph.language = "en".to_owned();
7117        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7118        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7119        assert!(!p.contains("does not apply"), "{p}");
7120    }
7121
7122    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7123
7124    fn panel() -> String {
7125        approval_panel(
7126            &run_state(),
7127            &green_pr(),
7128            NUMSTAT,
7129            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7130            &[
7131                "land: ask before merging".to_owned(),
7132                "land: colour the diff".to_owned(),
7133            ],
7134            "feat: merge approval from the phone",
7135        )
7136    }
7137
7138    #[test]
7139    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7140        let html = panel();
7141        for needle in [
7142            "42",
7143            "main",
7144            "src/land.rs",
7145            "src/web.rs",
7146            "assets/logo.png",
7147            "feat: merge approval from the phone",
7148            "land: ask before merging",
7149            "land: colour the diff",
7150            "coderabbitai",
7151            "this branch never checks the exit code",
7152            "green",
7153        ] {
7154            assert!(html.contains(needle), "the panel must state `{needle}`");
7155        }
7156    }
7157
7158    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7159        crate::run::pin_test_home();
7160        let tmp = tempfile::tempdir().unwrap();
7161        let repo = tmp.path().join("repo");
7162        let remote = tmp.path().join("remote.git");
7163        std::fs::create_dir(&repo).unwrap();
7164        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7165            .await
7166            .unwrap();
7167        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7168        git::git(&repo, &["config", "user.name", "Test"])
7169            .await
7170            .unwrap();
7171        git::git(&repo, &["config", "user.email", "test@example.test"])
7172            .await
7173            .unwrap();
7174        std::fs::write(repo.join("base"), "base").unwrap();
7175        git::git(&repo, &["add", "."]).await.unwrap();
7176        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7177        git::git(
7178            &repo,
7179            &["remote", "add", "origin", remote.to_str().unwrap()],
7180        )
7181        .await
7182        .unwrap();
7183        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7184        git::git(&repo, &["checkout", "-b", "winner"])
7185            .await
7186            .unwrap();
7187        std::fs::write(repo.join("change"), "change").unwrap();
7188        git::git(&repo, &["add", "."]).await.unwrap();
7189        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7190        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7191        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7192            .await
7193            .unwrap();
7194        let mut state = run_state();
7195        state.repo = repo.clone();
7196        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7197        state.config.graph.land = true;
7198        state.config.graph.land_approval = true;
7199        state.pr_head = Some("task-head".into());
7200        state.pr_pushed_head = Some(head.clone());
7201        state.candidates = vec![winning_candidate("change")];
7202        state.candidates[0].worktree = repo;
7203        state.candidates[0].branch = "winner".into();
7204        state.tally = Some(uncontested_tally());
7205        state.gate_ran = true;
7206        state.gate_head = Some(head.clone());
7207        state.land_sync_pending = true;
7208        state.save().unwrap();
7209        (tmp, state, head)
7210    }
7211
7212    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7213        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7214        resume_landing_head(state, head, &green_pr().url, &forge).await
7215    }
7216
7217    async fn advance_landing_base(state: &RunState) {
7218        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7219        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7220        git::git(&state.repo, &["add", "."]).await.unwrap();
7221        git::git(&state.repo, &["commit", "-m", "advance base"])
7222            .await
7223            .unwrap();
7224        git::git(&state.repo, &["push", "origin", "main"])
7225            .await
7226            .unwrap();
7227        git::git(&state.repo, &["checkout", "winner"])
7228            .await
7229            .unwrap();
7230    }
7231
7232    #[tokio::test]
7233    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7234        let (_tmp, mut state, old) = landing_git_fixture().await;
7235        advance_landing_base(&state).await;
7236        state.config.verify.gate = vec!["test -f advance".into()];
7237        let head = resume_observed_head(&mut state, &old).await.unwrap();
7238        assert_ne!(head, old);
7239        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7240        assert!(state.gate_allows_progress());
7241        assert_eq!(
7242            git::remote_head(&state.repo, "origin", "task-head")
7243                .await
7244                .unwrap(),
7245            Some(head.clone())
7246        );
7247        assert_eq!(state.pr_pushed_head, Some(head));
7248        assert!(!state.land_sync_pending);
7249        assert!(state.config.graph.land_approval);
7250    }
7251
7252    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7253        state.config.disk.min_free_bytes = 0;
7254        state.config.graph.gate_fix_rounds = 1;
7255        state.config.agents = vec![crate::config::AgentSpec {
7256            id: "mock".into(),
7257            kind: crate::config::AgentKind::Command,
7258            model: None,
7259            command: vec!["sh".into(), "-c".into(), script.into()],
7260            extra_args: Vec::new(),
7261            env: Default::default(),
7262            prompt_delivery: None,
7263            max_turn_secs: None,
7264        }];
7265        state.candidates[0].agent = "mock".into();
7266    }
7267
7268    #[tokio::test]
7269    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7270        let (_tmp, mut state, old) = landing_git_fixture().await;
7271        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7272        git::commit_all(&state.repo, "extend winner").await.unwrap();
7273        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7274        git::git(
7275            &state.repo,
7276            &["push", "origin", "HEAD:refs/heads/task-head"],
7277        )
7278        .await
7279        .unwrap();
7280        state.config.verify.gate = vec!["exit 1".into()];
7281        state.config.graph.gate_fix_rounds = 0;
7282        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7283        assert_ne!(old, updated);
7284        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7285        assert!(!state.gate_allows_progress());
7286        assert!(state.land_sync_pending);
7287        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7288        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7289        assert_eq!(head, updated);
7290        assert!(state.gate_allows_progress());
7291        assert!(!state.land_sync_pending);
7292        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7293        land_with(&mut state, &green_pr().url, &forge)
7294            .await
7295            .unwrap();
7296        assert!(state.parked);
7297        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7298        assert!(forge.argvs().is_empty());
7299    }
7300
7301    #[tokio::test]
7302    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7303        for divergent in [false, true] {
7304            let (_tmp, mut state, old) = landing_git_fixture().await;
7305            if divergent {
7306                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7307                    .await
7308                    .unwrap();
7309            }
7310            std::fs::write(state.repo.join("external"), "external").unwrap();
7311            git::commit_all(&state.repo, "external update")
7312                .await
7313                .unwrap();
7314            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7315            git::git(
7316                &state.repo,
7317                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7318            )
7319            .await
7320            .unwrap();
7321            if !divergent {
7322                git::git(&state.repo, &["reset", "--hard", &old])
7323                    .await
7324                    .unwrap();
7325            }
7326            let error = resume_observed_head(&mut state, &external)
7327                .await
7328                .unwrap_err();
7329            assert!(
7330                error
7331                    .to_string()
7332                    .contains("public head moved outside this run")
7333            );
7334            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7335            assert_eq!(
7336                git::remote_head(&state.repo, "origin", "task-head")
7337                    .await
7338                    .unwrap(),
7339                Some(external)
7340            );
7341        }
7342    }
7343
7344    #[tokio::test]
7345    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7346        let (_tmp, mut state, old) = landing_git_fixture().await;
7347        advance_landing_base(&state).await;
7348        landing_gate_fixer(
7349            &mut state,
7350            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7351        );
7352        state.config.verify.gate =
7353            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7354        state.config.verify.e2e = vec!["test -f advance".into()];
7355        // First save a red gate without spending a fixer round. Resume must
7356        // still offer the remaining budget for this exact same failed head.
7357        state.config.graph.gate_fix_rounds = 0;
7358        assert!(resume_observed_head(&mut state, &old).await.is_err());
7359        assert_eq!(
7360            git::remote_head(&state.repo, "origin", "task-head")
7361                .await
7362                .unwrap(),
7363            Some(old.clone())
7364        );
7365        state.config.graph.gate_fix_rounds = 1;
7366        let head = resume_observed_head(&mut state, &old).await.unwrap();
7367        assert_eq!(state.gate_fixes.len(), 1);
7368        assert!(state.gate_fixes[0].committed);
7369        assert!(state.gate_allows_progress());
7370        assert_eq!(state.gate_head.as_ref(), Some(&head));
7371        assert_eq!(
7372            git::remote_head(&state.repo, "origin", "task-head")
7373                .await
7374                .unwrap(),
7375            Some(head.clone())
7376        );
7377        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7378        land_with(&mut state, &green_pr().url, &forge)
7379            .await
7380            .unwrap();
7381        assert!(state.parked);
7382        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7383        assert!(forge.argvs().is_empty());
7384    }
7385
7386    #[tokio::test]
7387    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7388        let (_tmp, mut state, old) = landing_git_fixture().await;
7389        advance_landing_base(&state).await;
7390        landing_gate_fixer(
7391            &mut state,
7392            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7393        );
7394        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7395        for _ in 0..2 {
7396            assert!(resume_observed_head(&mut state, &old).await.is_err());
7397            assert_eq!(state.gate_fixes.len(), 1);
7398            assert_eq!(
7399                git::remote_head(&state.repo, "origin", "task-head")
7400                    .await
7401                    .unwrap(),
7402                Some(old.clone())
7403            );
7404        }
7405    }
7406
7407    #[tokio::test]
7408    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7409        let (_tmp, mut state, old) = landing_git_fixture().await;
7410        advance_landing_base(&state).await;
7411        landing_gate_fixer(
7412            &mut state,
7413            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7414        );
7415        state.config.verify.gate =
7416            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7417        // The repair makes the gate green while breaking an e2e invariant.
7418        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7419        for resume in 0..3 {
7420            assert!(resume_observed_head(&mut state, &old).await.is_err());
7421            state = RunState::load(&state.id).unwrap();
7422            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7423            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7424            assert_eq!(evidence.head, head);
7425            assert!(!evidence.passed);
7426            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7427            assert_eq!(state.gate_fixes.len(), 1);
7428            assert_eq!(
7429                git::remote_head(&state.repo, "origin", "task-head")
7430                    .await
7431                    .unwrap(),
7432                Some(old.clone())
7433            );
7434            if resume > 0 {
7435                assert!(
7436                    state.gate_allows_progress(),
7437                    "a green gate must not bypass red e2e"
7438                );
7439            }
7440        }
7441        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7442        // can be satisfied without another fixer or approval bypass.
7443        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7444        let head = resume_observed_head(&mut state, &old).await.unwrap();
7445        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7446        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7447        assert_eq!(state.gate_fixes.len(), 1);
7448        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7449        land_with(&mut state, &green_pr().url, &forge)
7450            .await
7451            .unwrap();
7452        assert!(state.parked);
7453        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7454        assert!(forge.argvs().is_empty());
7455    }
7456
7457    #[tokio::test]
7458    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7459        for phase in 0..3 {
7460            let (_tmp, mut state, old) = landing_git_fixture().await;
7461            advance_landing_base(&state).await;
7462            let head = resume_observed_head(&mut state, &old).await.unwrap();
7463            // Persist the shapes left by interruption, resource deferral,
7464            // and a successful check for a different (older) SHA.
7465            state.land_sync_pending = true;
7466            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7467            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7468                head: if phase == 2 {
7469                    old.clone()
7470                } else {
7471                    head.clone()
7472                },
7473                passed: phase == 2,
7474                outcomes: if phase == 1 {
7475                    vec![crate::run::CommandOutcome {
7476                        command: state.config.verify.e2e[0].clone(),
7477                        code: None,
7478                        output_tail: "verification resource unavailable".into(),
7479                        duration_ms: 0,
7480                        resource_blocked: true,
7481                    }]
7482                } else {
7483                    Vec::new()
7484                },
7485            });
7486            state.save().unwrap();
7487            for _ in 0..2 {
7488                state = RunState::load(&state.id).unwrap();
7489                assert!(resume_observed_head(&mut state, &head).await.is_err());
7490                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7491                assert_eq!(evidence.head, head);
7492                assert!(!evidence.passed);
7493                assert!(!evidence.outcomes.is_empty());
7494                assert_eq!(
7495                    git::remote_head(&state.repo, "origin", "task-head")
7496                        .await
7497                        .unwrap(),
7498                    Some(head.clone())
7499                );
7500                assert!(state.land_sync_pending);
7501            }
7502        }
7503    }
7504
7505    async fn interrupt_landing_rebase(state: &mut RunState) {
7506        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7507        state.save().unwrap();
7508        let scratch = state.dir().join("rebase");
7509        assert_eq!(
7510            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7511                .await
7512                .unwrap(),
7513            crate::rebase::Rebased::Applied
7514        );
7515        assert!(!git::is_clean(&state.repo).await.unwrap());
7516    }
7517
7518    async fn landing_setup(state: &mut RunState) {
7519        git::git(
7520            &state.repo,
7521            &["config", "extensions.worktreeConfig", "true"],
7522        )
7523        .await
7524        .unwrap();
7525        state.config.worktree.setup =
7526            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7527        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7528            .await
7529            .unwrap();
7530        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7531    }
7532
7533    #[tokio::test]
7534    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7535        for phase in 0..3 {
7536            let (_tmp, mut state, old) = landing_git_fixture().await;
7537            advance_landing_base(&state).await;
7538            landing_setup(&mut state).await;
7539            if phase > 0 {
7540                interrupt_landing_rebase(&mut state).await;
7541                if phase == 2 {
7542                    // Crash after clean removed setup products, before setup.
7543                    git::sync_to_head(&state.repo).await.unwrap();
7544                    assert!(!state.repo.join(".env").exists());
7545                }
7546                state = RunState::load(&state.id).unwrap();
7547            }
7548            let head = resume_observed_head(&mut state, &old).await.unwrap();
7549            assert_ne!(head, old);
7550            assert!(state.gate_allows_progress());
7551            assert!(state.land_rebase_from.is_none());
7552            assert_eq!(
7553                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7554                "secret"
7555            );
7556            assert!(git::is_clean(&state.repo).await.unwrap());
7557            assert!(
7558                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7559                    .await
7560                    .unwrap()
7561                    .contains(".env")
7562            );
7563        }
7564    }
7565
7566    #[tokio::test]
7567    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7568        let (_tmp, mut state, _) = landing_git_fixture().await;
7569        advance_landing_base(&state).await;
7570        landing_setup(&mut state).await;
7571        interrupt_landing_rebase(&mut state).await;
7572        // Exercise unusual names without asking Windows to create an invalid
7573        // control-character filename. Unix also covers embedded newlines.
7574        let locals = [
7575            "local notes",
7576            #[cfg(unix)]
7577            "local\nnotes",
7578        ];
7579        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7580            .await
7581            .unwrap();
7582        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7583        for local in locals {
7584            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7585            assert!(
7586                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7587                    .await
7588                    .unwrap()
7589                    .ok()
7590            );
7591        }
7592        restore_landing_worktree(&mut state).await.unwrap();
7593        for local in locals {
7594            assert_eq!(
7595                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7596                "keep these notes"
7597            );
7598        }
7599        assert_eq!(
7600            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7601            "secret"
7602        );
7603        assert!(git::is_clean(&state.repo).await.unwrap());
7604        assert!(state.land_rebase_from.is_none());
7605    }
7606
7607    #[tokio::test]
7608    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7609        for local in ["advance", "advance/notes", "new-directory"] {
7610            let (_tmp, mut state, _) = landing_git_fixture().await;
7611            advance_landing_base(&state).await;
7612            // Also cover a local file blocking a new tracked directory.
7613            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7614            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7615            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7616            git::git(&state.repo, &["add", "."]).await.unwrap();
7617            git::git(&state.repo, &["commit", "-m", "add directory"])
7618                .await
7619                .unwrap();
7620            git::git(&state.repo, &["push", "origin", "main"])
7621                .await
7622                .unwrap();
7623            git::git(&state.repo, &["checkout", "winner"])
7624                .await
7625                .unwrap();
7626            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7627                .await
7628                .unwrap();
7629            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7630            let path = state.repo.join(local);
7631            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7632            std::fs::write(&path, "local notes").unwrap();
7633            interrupt_landing_rebase(&mut state).await;
7634            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7635            let checkpoint = state.land_rebase_from.clone();
7636            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7637            assert!(
7638                error.to_string().contains("conflicting with HEAD"),
7639                "{error:#}"
7640            );
7641            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7642            assert_eq!(
7643                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7644                before
7645            );
7646            assert_eq!(state.land_rebase_from, checkpoint);
7647        }
7648    }
7649
7650    #[tokio::test]
7651    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7652        for interrupted in [false, true] {
7653            let (_tmp, mut state, old) = landing_git_fixture().await;
7654            assert!(state.config.worktree.setup.is_empty());
7655            // A gate product exists before the base moves, without setup metadata.
7656            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7657                .await
7658                .unwrap();
7659            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7660            state.config.verify.gate =
7661                vec!["mkdir -p target && printf artifact > target/probe".into()];
7662            state.gate_ran = false;
7663            resume_observed_head(&mut state, &old).await.unwrap();
7664            assert_eq!(
7665                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7666                "artifact"
7667            );
7668            advance_landing_base(&state).await;
7669            if interrupted {
7670                interrupt_landing_rebase(&mut state).await;
7671                state = RunState::load(&state.id).unwrap();
7672            }
7673            state.config.verify.gate =
7674                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7675            let head = resume_observed_head(&mut state, &old).await.unwrap();
7676            assert_ne!(head, old);
7677            assert!(state.gate_allows_progress());
7678            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7679            assert_eq!(
7680                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7681                "artifact"
7682            );
7683            assert!(state.land_rebase_from.is_none());
7684            assert_eq!(
7685                git::remote_head(&state.repo, "origin", "task-head")
7686                    .await
7687                    .unwrap(),
7688                Some(head.clone())
7689            );
7690            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7691            land_with(&mut state, &green_pr().url, &forge)
7692                .await
7693                .unwrap();
7694            assert!(state.parked);
7695            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7696            assert!(forge.argvs().is_empty());
7697        }
7698    }
7699
7700    #[tokio::test]
7701    #[cfg(unix)]
7702    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7703        use std::os::fd::AsRawFd as _;
7704
7705        for interrupted in [false, true] {
7706            let (_tmp, mut state, _) = landing_git_fixture().await;
7707            advance_landing_base(&state).await;
7708            landing_setup(&mut state).await;
7709            if interrupted {
7710                interrupt_landing_rebase(&mut state).await;
7711            }
7712            state.config.worktree.setup =
7713                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7714            let common = crate::repo_lock::common_dir(&state.repo)
7715                .await
7716                .unwrap()
7717                .unwrap();
7718            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7719            state.config.coordination = crate::config::test_coordination();
7720            state.config.graph.timeout_resource_wait = 0;
7721            let external = std::fs::OpenOptions::new()
7722                .read(true)
7723                .write(true)
7724                .create(true)
7725                .truncate(false)
7726                .open(common.join("updater.lock"))
7727                .unwrap();
7728            // SAFETY: the descriptor belongs to `external` and remains open.
7729            let locked =
7730                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7731            assert_eq!(locked, 0);
7732            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7733            let checkpoint = state.land_rebase_from.clone();
7734            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7735            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7736            assert_eq!(
7737                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7738                before
7739            );
7740            assert_eq!(state.land_rebase_from, checkpoint);
7741            assert_eq!(
7742                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7743                "secret"
7744            );
7745
7746            drop(external);
7747            state.config.graph.timeout_resource_wait = 2;
7748            restore_landing_worktree(&mut state).await.unwrap();
7749            assert!(git::is_clean(&state.repo).await.unwrap());
7750            assert!(state.land_rebase_from.is_none());
7751            assert_eq!(
7752                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7753                "restored"
7754            );
7755        }
7756    }
7757
7758    #[tokio::test]
7759    #[cfg(unix)]
7760    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7761        use std::sync::atomic::{AtomicBool, Ordering};
7762
7763        let (_tmp, mut state, _) = landing_git_fixture().await;
7764        advance_landing_base(&state).await;
7765        landing_setup(&mut state).await;
7766        interrupt_landing_rebase(&mut state).await;
7767        let common = crate::repo_lock::common_dir(&state.repo)
7768            .await
7769            .unwrap()
7770            .unwrap();
7771        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7772        state.config.coordination = crate::config::test_coordination();
7773        let holder = crate::repo_lock::SeatLease::for_checkout(
7774            &state.repo,
7775            true,
7776            state.config.coordination.as_ref(),
7777        )
7778        .await
7779        .unwrap();
7780        assert!(holder.is_some());
7781        let cancelled = Arc::new(AtomicBool::new(false));
7782        let signal = cancelled.clone();
7783        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7784            signal.load(Ordering::SeqCst)
7785        });
7786        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7787        let checkpoint = state.land_rebase_from.clone();
7788        let (result, ()) = tokio::join!(
7789            tokio::time::timeout(
7790                Duration::from_secs(2),
7791                wait.scope(restore_landing_worktree(&mut state)),
7792            ),
7793            async {
7794                tokio::time::sleep(Duration::from_millis(100)).await;
7795                cancelled.store(true, Ordering::SeqCst);
7796            }
7797        );
7798        let error = result
7799            .expect("cancellation must release the waiting driver")
7800            .unwrap_err();
7801        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7802        assert_eq!(
7803            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7804            before
7805        );
7806        assert_eq!(state.land_rebase_from, checkpoint);
7807        assert_eq!(
7808            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7809            "secret"
7810        );
7811        drop(holder);
7812        restore_landing_worktree(&mut state).await.unwrap();
7813        assert!(state.land_rebase_from.is_none());
7814        assert!(git::is_clean(&state.repo).await.unwrap());
7815    }
7816
7817    #[tokio::test]
7818    #[cfg(unix)]
7819    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
7820        use std::sync::atomic::{AtomicBool, Ordering};
7821
7822        for e2e in [false, true] {
7823            let (_tmp, mut state, head) = landing_git_fixture().await;
7824            let common = crate::repo_lock::common_dir(&state.repo)
7825                .await
7826                .unwrap()
7827                .unwrap();
7828            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7829            state.config.coordination = crate::config::test_coordination();
7830            if e2e {
7831                state.config.verify.e2e = vec!["true".into()];
7832                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7833                    head: head.clone(),
7834                    passed: false,
7835                    outcomes: Vec::new(),
7836                });
7837            } else {
7838                state.config.verify.gate = vec!["true".into()];
7839                state.gate_ran = false;
7840            }
7841            let holder = crate::repo_lock::SeatLease::for_checkout(
7842                &state.repo,
7843                true,
7844                state.config.coordination.as_ref(),
7845            )
7846            .await
7847            .unwrap();
7848            assert!(holder.is_some());
7849            let cancelled = Arc::new(AtomicBool::new(false));
7850            let signal = cancelled.clone();
7851            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
7852                signal.load(Ordering::SeqCst)
7853            });
7854            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7855            let url = green_pr().url;
7856            let (result, ()) = tokio::join!(
7857                tokio::time::timeout(
7858                    Duration::from_secs(2),
7859                    wait.scope(publish_landing_head(&mut state, &head, &url, &forge)),
7860                ),
7861                async {
7862                    tokio::time::sleep(Duration::from_millis(100)).await;
7863                    cancelled.store(true, Ordering::SeqCst);
7864                }
7865            );
7866            let error = result
7867                .expect("cancellation must release the verification waiter")
7868                .unwrap_err();
7869            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7870            assert!(forge.argvs().is_empty());
7871            assert_eq!(
7872                git::remote_head(&state.repo, "origin", "task-head")
7873                    .await
7874                    .unwrap(),
7875                Some(head.clone())
7876            );
7877            drop(holder);
7878            // Cancellation must release its reservation so another attempt can verify.
7879            assert_eq!(
7880                publish_landing_head(&mut state, &head, &url, &forge)
7881                    .await
7882                    .unwrap(),
7883                head
7884            );
7885        }
7886    }
7887
7888    #[tokio::test]
7889    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
7890        for staged in [false, true] {
7891            let (_tmp, mut state, old) = landing_git_fixture().await;
7892            advance_landing_base(&state).await;
7893            interrupt_landing_rebase(&mut state).await;
7894            std::fs::write(state.repo.join("change"), "user edit").unwrap();
7895            if staged {
7896                git::git(&state.repo, &["add", "change"]).await.unwrap();
7897            }
7898            assert!(resume_observed_head(&mut state, &old).await.is_err());
7899            assert_eq!(
7900                std::fs::read_to_string(state.repo.join("change")).unwrap(),
7901                "user edit"
7902            );
7903            assert_eq!(
7904                git::remote_head(&state.repo, "origin", "task-head")
7905                    .await
7906                    .unwrap(),
7907                Some(old)
7908            );
7909        }
7910    }
7911
7912    #[tokio::test]
7913    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
7914        let (_tmp, mut state, head) = landing_git_fixture().await;
7915        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7916        let id = state.id.clone();
7917        let pr = land_with(&mut state, &green_pr().url, &forge)
7918            .await
7919            .unwrap();
7920        assert_eq!(pr.state, PrLifecycle::Open);
7921        assert_eq!(state.id, id);
7922        assert!(state.parked);
7923        assert_eq!(state.status, RunStatus::Landing);
7924        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7925        assert!(forge.argvs().is_empty());
7926
7927        state.land_sync_pending = true;
7928        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
7929        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
7930        closed.pr.state = PrLifecycle::Closed;
7931        let forge = Scripted::new(vec![closed], vec![]);
7932        assert_eq!(
7933            land_with(&mut state, &green_pr().url, &forge)
7934                .await
7935                .unwrap()
7936                .state,
7937            PrLifecycle::Closed
7938        );
7939        assert_eq!(
7940            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
7941            "do not touch"
7942        );
7943        assert!(forge.argvs().is_empty());
7944    }
7945
7946    #[tokio::test]
7947    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
7948        let (_tmp, mut state, old) = landing_git_fixture().await;
7949        advance_landing_base(&state).await;
7950        state.config.verify.gate = vec!["exit 1".into()];
7951        assert!(resume_observed_head(&mut state, &old).await.is_err());
7952        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
7953        assert!(!state.gate_allows_progress());
7954        assert_eq!(
7955            git::remote_head(&state.repo, "origin", "task-head")
7956                .await
7957                .unwrap(),
7958            Some(old)
7959        );
7960        assert!(state.land_sync_pending);
7961    }
7962
7963    #[tokio::test]
7964    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
7965        let (_tmp, mut state, old) = landing_git_fixture().await;
7966        advance_landing_base(&state).await;
7967        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
7968        // Model a crash after the push but before saving its published SHA.
7969        state.pr_pushed_head = Some(old.clone());
7970        state.land_sync_pending = true;
7971        assert_eq!(
7972            resume_observed_head(&mut state, &pushed).await.unwrap(),
7973            pushed
7974        );
7975        // A writer races the observed PR snapshot: the explicit remote lease
7976        // must reject the push even though preflight saw our expected head.
7977        git::git(
7978            &state.repo,
7979            &["push", "--force", "origin", "main:refs/heads/task-head"],
7980        )
7981        .await
7982        .unwrap();
7983        assert!(
7984            publish_landing_head(
7985                &mut state,
7986                &pushed,
7987                &green_pr().url,
7988                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
7989            )
7990            .await
7991            .unwrap_err()
7992            .to_string()
7993            .contains("publishing gated")
7994        );
7995        assert!(
7996            resume_observed_head(&mut state, "foreign-head")
7997                .await
7998                .is_err()
7999        );
8000    }
8001    #[tokio::test]
8002    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8003        let (_tmp, mut state, head) = landing_git_fixture().await;
8004        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8005        closed.pr.state = PrLifecycle::Closed;
8006        let forge = Scripted::new(vec![closed], vec![]);
8007        assert!(
8008            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8009                .await
8010                .unwrap_err()
8011                .to_string()
8012                .contains("PR closed or merged")
8013        );
8014    }
8015
8016    /// A candidate selected by the uncontested tally.
8017    fn winning_candidate(summary: &str) -> Candidate {
8018        Candidate {
8019            index: 0,
8020            label: 'A',
8021            agent: "opus".to_owned(),
8022            branch: "magi/x/A".to_owned(),
8023            worktree: PathBuf::from("/wt/A"),
8024            summary: summary.to_owned(),
8025            stat: String::new(),
8026            files: 1,
8027            commits: 1,
8028            empty: false,
8029            failed: None,
8030            verified_noop: None,
8031            duration_ms: 0,
8032            folded: false,
8033        }
8034    }
8035
8036    fn uncontested_tally() -> Tally {
8037        Tally {
8038            first_choice: BTreeMap::from([('A', 1)]),
8039            borda: BTreeMap::new(),
8040            winner: 'A',
8041            rankings: 1,
8042            unanimous_initial: true,
8043            deliberated: false,
8044            changed_votes: 0,
8045            unanimous_final: true,
8046            tie_break: None,
8047            judges: 1,
8048            present: 1,
8049            quorum: 1,
8050            met_quorum: true,
8051            uncontested: None,
8052        }
8053    }
8054
8055    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8056        ReviewRecord {
8057            attempts: 0,
8058            reviewer,
8059            agent: agent.to_owned(),
8060            summary: summary.to_owned(),
8061            findings: Vec::new(),
8062            vote: None,
8063            failed: None,
8064            duration_ms: 0,
8065        }
8066    }
8067
8068    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8069        let answered = reviews.len();
8070        ReviewRound {
8071            round,
8072            head: "abc1234".to_owned(),
8073            verified_head: None,
8074            verified_at: None,
8075            reviews,
8076            e2e: Vec::new(),
8077            verify_retried: false,
8078            e2e_deferred: false,
8079            e2e_defer_reason: None,
8080            fix: None,
8081            blocking: 0,
8082            answered,
8083            expected: answered,
8084            clean: true,
8085            progressed: false,
8086            vote_split: false,
8087            reconsideration: Vec::new(),
8088            verdict: None,
8089        }
8090    }
8091
8092    #[test]
8093    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8094        let en = panel();
8095        assert!(en.contains("Task"), "{en}");
8096        assert!(en.contains("add retries to the uploader"), "{en}");
8097
8098        let mut state = run_state();
8099        state.config.graph.language = "ja".to_owned();
8100        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8101        assert!(ja.contains("タスク"), "{ja}");
8102        assert!(
8103            ja.contains("add retries to the uploader"),
8104            "the task itself is not translated: {ja}"
8105        );
8106    }
8107
8108    #[test]
8109    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8110        // `run_state()` has no candidates, no tally and no reviews - exactly
8111        // the shape a run has before anything has judged or reviewed it, and
8112        // the panel must not print an empty box for either.
8113        let html = panel();
8114        assert!(!html.contains("What changed"), "{html}");
8115        assert!(!html.contains("Review verdict"), "{html}");
8116    }
8117
8118    #[test]
8119    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8120        let mut state = run_state();
8121        state.candidates = vec![winning_candidate("")];
8122        state.tally = Some(uncontested_tally());
8123        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8124        assert!(
8125            !html.contains("What changed"),
8126            "an empty summary must not render an empty box: {html}"
8127        );
8128    }
8129
8130    #[test]
8131    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8132        let mut state = run_state();
8133        state.candidates = vec![winning_candidate(
8134            "Added a retry loop around the uploader PUT call.",
8135        )];
8136        state.tally = Some(uncontested_tally());
8137        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8138        assert!(en.contains("What changed"), "{en}");
8139        assert!(
8140            en.contains("Added a retry loop around the uploader PUT call."),
8141            "{en}"
8142        );
8143
8144        state.config.graph.language = "ja".to_owned();
8145        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8146        assert!(ja.contains("変更内容"), "{ja}");
8147        assert!(
8148            ja.contains("Added a retry loop around the uploader PUT call."),
8149            "{ja}"
8150        );
8151    }
8152
8153    #[test]
8154    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8155        let mut state = run_state();
8156        state.reviews = vec![
8157            review_round(
8158                1,
8159                vec![review_record(1, "alpha", "found a race, sent back")],
8160            ),
8161            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8162        ];
8163        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8164        assert!(en.contains("Review verdict"), "{en}");
8165        assert!(en.contains("race is fixed, clean"), "{en}");
8166        assert!(
8167            !en.contains("found a race, sent back"),
8168            "only the round that actually cleared the merge should show: {en}"
8169        );
8170
8171        state.config.graph.language = "ja".to_owned();
8172        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8173        assert!(ja.contains("レビューの結論"), "{ja}");
8174        assert!(ja.contains("レビュアー"), "{ja}");
8175        assert!(ja.contains("race is fixed, clean"), "{ja}");
8176    }
8177
8178    /// The `incomplete_review = "warn"` policy (see
8179    /// `graph::Runner::review_loop`) can push a `clean` round to
8180    /// `state.reviews` while one seat's own record still has `failed: Some`
8181    /// and an empty `summary` - a seat that never answered, not one that
8182    /// answered with nothing to say.
8183    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8184        ReviewRecord {
8185            attempts: 0,
8186            reviewer,
8187            agent: agent.to_owned(),
8188            summary: String::new(),
8189            findings: Vec::new(),
8190            vote: None,
8191            failed: Some(reason.to_owned()),
8192            duration_ms: 0,
8193        }
8194    }
8195
8196    #[test]
8197    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8198        let mut state = run_state();
8199        state.reviews = vec![review_round(
8200            1,
8201            vec![
8202                review_record(1, "alpha", "clean, nothing to add"),
8203                unanswered_review_record(2, "beta", "timed out"),
8204            ],
8205        )];
8206        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8207        assert!(en.contains("clean, nothing to add"), "{en}");
8208        assert!(
8209            en.contains("produced no answer: timed out"),
8210            "a seat that never answered must say so, not render a blank box: {en}"
8211        );
8212        assert!(
8213            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8214            "no reviewer box may be left empty: {en}"
8215        );
8216
8217        state.config.graph.language = "ja".to_owned();
8218        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8219        assert!(ja.contains("回答なし: timed out"), "{ja}");
8220    }
8221
8222    #[test]
8223    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8224        let html = panel();
8225        assert!(!html.contains("<script"), "no script survives the csp");
8226        assert!(!html.contains("<form"), "form-action is 'none'");
8227        let pr = green_pr();
8228        assert_eq!(
8229            html.matches("http").count(),
8230            html.matches(pr.url.as_str()).count(),
8231            "the only http url in the panel is the pull request's own link"
8232        );
8233        assert!(
8234            html.contains("target=\"_blank\""),
8235            "the PR link must open in a new tab so it escapes the iframe"
8236        );
8237        assert!(
8238            html.contains("rel=\"noopener noreferrer\""),
8239            "external links opening in new tabs must carry noopener noreferrer"
8240        );
8241    }
8242
8243    #[test]
8244    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8245        let html = panel();
8246        assert!(
8247            html.contains(">+</span>"),
8248            "an added line carries a `+` in the gutter, not only a background"
8249        );
8250        assert!(
8251            html.contains(">-</span>"),
8252            "a removed line carries a `-` in the gutter, not only a background"
8253        );
8254        assert!(
8255            html.contains(">new line</span>"),
8256            "the marker is moved to the gutter, so the body is printed once without it"
8257        );
8258    }
8259
8260    #[test]
8261    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8262        let total = DIFF_MAX_LINES + 100;
8263        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8264        let html = approval_panel(
8265            &run_state(),
8266            &green_pr(),
8267            NUMSTAT,
8268            &diff,
8269            &[],
8270            "feat: something long",
8271        );
8272        assert!(
8273            html.contains(&format!("100 of {total} diff lines omitted")),
8274            "the note must say exactly how much was cut"
8275        );
8276        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8277        assert!(
8278            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8279            "nothing past the threshold is rendered"
8280        );
8281        assert!(
8282            html.contains("/repo/magi"),
8283            "the note says where the rest is"
8284        );
8285    }
8286
8287    #[test]
8288    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8289        let html = approval_panel(
8290            &run_state(),
8291            &green_pr(),
8292            "1\t2\tsrc/<b>&\"x\"'.rs",
8293            "",
8294            &[],
8295            "subject",
8296        );
8297        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8298        assert!(
8299            !html.contains("<b>"),
8300            "an agent-influenced path must never become markup"
8301        );
8302    }
8303
8304    #[tokio::test]
8305    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8306        let a = std::path::PathBuf::from("/repo/a");
8307        let b = std::path::PathBuf::from("/repo/b");
8308
8309        let held = repo_merge_lock(&a).lock_owned().await;
8310
8311        // A second, concurrent land run against the *same* repository must
8312        // wait - `try_lock` fails while `held` is alive.
8313        assert!(
8314            repo_merge_lock(&a).try_lock().is_err(),
8315            "a second merge into the same repository must not proceed concurrently"
8316        );
8317
8318        // A run against a *different* repository must not be blocked by it -
8319        // this is what keeps a slow rebase or `gh pr merge` in one
8320        // repository from also stalling a land-approval resume in another.
8321        assert!(
8322            repo_merge_lock(&b).try_lock().is_ok(),
8323            "a different repository's merge lock must be independent"
8324        );
8325
8326        drop(held);
8327        assert!(
8328            repo_merge_lock(&a).try_lock().is_ok(),
8329            "the lock is released once the holder is done"
8330        );
8331    }
8332
8333    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8334        let mut state = landing_state();
8335        let mut rec = review_record(1, "alpha", "precise rejection reason");
8336        rec.vote = Some(if reject {
8337            crate::verdict::ReviewVote::Reject
8338        } else {
8339            crate::verdict::ReviewVote::Approve
8340        });
8341        if major {
8342            rec.findings = contested().findings;
8343        }
8344        let mut round = review_round(1, vec![rec]);
8345        round.clean = false;
8346        round.blocking = usize::from(major);
8347        state.reviews.push(round);
8348        state.config.graph.review_rounds = 1;
8349        state
8350    }
8351
8352    #[test]
8353    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8354        for every in [false, true] {
8355            for contested in [false, true] {
8356                for major in [false, true] {
8357                    for reject in [false, true] {
8358                        let mut state = repair_state(major, reject);
8359                        state.config.graph.land_approval = every;
8360                        state.config.graph.hold_contested_merge = contested;
8361                        let reasons = approval_reasons(&state);
8362                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8363                        assert_eq!(
8364                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8365                            contested && major
8366                        );
8367                        assert_eq!(
8368                            reasons.contains(&ApprovalReason::FinalReject),
8369                            contested && reject
8370                        );
8371                        assert_eq!(
8372                            reasons.len(),
8373                            usize::from(every)
8374                                + usize::from(contested && major)
8375                                + usize::from(contested && reject)
8376                        );
8377                    }
8378                }
8379            }
8380        }
8381    }
8382
8383    #[test]
8384    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8385        use crate::verdict::ReviewVote::{Approve, Reject};
8386        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8387            let mut state = repair_state(false, initial_reject);
8388            state.reviews[0]
8389                .reconsideration
8390                .push(crate::run::ReviewRevoteRecord {
8391                    reviewer: 1,
8392                    agent: "alpha".into(),
8393                    vote: Some(final_vote),
8394                    reason: "changed after reading the panel <evidence>".into(),
8395                    failed: None,
8396                });
8397            assert_eq!(
8398                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8399                final_vote == Reject
8400            );
8401            for language in ["en", "ja"] {
8402                state.config.graph.language = language.into();
8403                let evidence = approval_evidence(&state);
8404                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8405                assert!(evidence.contains("changed after reading the panel <evidence>"));
8406                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8407                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8408                assert!(!html.contains("<evidence>"));
8409            }
8410            state.reviews[0].reconsideration[0].vote = None;
8411            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8412            assert_eq!(
8413                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8414                initial_reject
8415            );
8416            assert!(approval_evidence(&state).contains("timed out"));
8417        }
8418    }
8419
8420    #[tokio::test]
8421    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8422        for every in [false, true] {
8423            for contested in [false, true] {
8424                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8425                {
8426                    let mut state = repair_state(major, reject);
8427                    state.config.graph.land_approval = every;
8428                    state.config.graph.hold_contested_merge = contested;
8429                    let forge = Scripted::new(
8430                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8431                        vec![(true, "")],
8432                    );
8433                    land_with(&mut state, URL, &forge).await.unwrap();
8434                    let expected = every || (contested && (major || reject));
8435                    assert_eq!(state.land_approval.is_some(), expected);
8436                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8437                    if expected {
8438                        assert!(state.parked);
8439                    }
8440                }
8441            }
8442        }
8443    }
8444
8445    #[tokio::test]
8446    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8447        for (base, control) in [
8448            (false, "land_approval"),
8449            (true, "land_approval"),
8450            (false, "hold_contested_merge"),
8451        ] {
8452            let (_tmp, mut state, _) = landing_git_fixture().await;
8453            state.config.graph.land_approval = base;
8454            state.config.graph.hold_contested_merge = false;
8455            state.reviewed_commits = Some(Vec::new());
8456            if control == "hold_contested_merge" {
8457                state.reviews = repair_state(false, true).reviews;
8458            }
8459            std::fs::write(
8460                state.repo.join("magi.toml"),
8461                format!("[graph]\n{control} = {}\n", !base),
8462            )
8463            .unwrap();
8464            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8465            git::git(&state.repo, &["commit", "-m", "approval policy"])
8466                .await
8467                .unwrap();
8468            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8469            state.land_sync_pending = false;
8470            state.pr_pushed_head = Some(head.clone());
8471            state.gate_head = Some(head.clone());
8472            let forge = Scripted::new(
8473                vec![seen(&head, Checks::Green, "CLEAN", false)],
8474                vec![(true, "")],
8475            );
8476            land_with(&mut state, URL, &forge).await.unwrap();
8477            assert!(
8478                state.land_approval.is_some(),
8479                "base={base}, control={control}"
8480            );
8481            assert!(!forge.calls().contains(&"merge"));
8482            if !base {
8483                let question = ask::Questions::open()
8484                    .get(&state.land_approval.as_ref().unwrap().question)
8485                    .unwrap();
8486                assert!(question.detail.contains(&format!(
8487                    "{control}=true from the reviewed branch's magi.toml"
8488                )));
8489            }
8490        }
8491    }
8492
8493    #[tokio::test]
8494    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8495        let (_tmp, mut state, _) = landing_git_fixture().await;
8496        state.config.graph.land_approval = false;
8497        state.config.graph.hold_contested_merge = false;
8498        state.reviewed_commits = Some(Vec::new());
8499        std::fs::write(
8500            state.repo.join("magi.toml"),
8501            "[graph]\nland_approval = true\n[verify]\ngate = ['untrusted']\n",
8502        )
8503        .unwrap();
8504        git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8505        git::git(&state.repo, &["commit", "-m", "require approval"])
8506            .await
8507            .unwrap();
8508        let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8509        let gate = state.config.verify.gate.clone();
8510        state.observe_branch_owner_holds(&head).await;
8511        assert_eq!(state.config.verify.gate, gate);
8512        assert_eq!(approval_reasons(&state), [ApprovalReason::EveryMerge]);
8513        git::git(&state.repo, &["rm", "magi.toml"]).await.unwrap();
8514        git::git(&state.repo, &["commit", "-m", "remove config"])
8515            .await
8516            .unwrap();
8517        let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8518        state = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
8519        state.observe_branch_owner_holds(&later).await;
8520        assert_eq!(state.branch_owner_holds[0].head, head);
8521        state.land_sync_pending = false;
8522        state.pr_pushed_head = Some(later.clone());
8523        state.gate_head = Some(later.clone());
8524        let forge = Scripted::new(
8525            vec![seen(&later, Checks::Green, "CLEAN", false)],
8526            vec![(true, "")],
8527        );
8528        land_with(&mut state, URL, &forge).await.unwrap();
8529        assert!(!forge.calls().contains(&"merge"));
8530        let question = ask::Questions::open()
8531            .get(&state.land_approval.as_ref().unwrap().question)
8532            .unwrap();
8533        assert!(question.detail.contains("Approval required because:"));
8534        assert!(
8535            question
8536                .detail
8537                .contains("land_approval=true from the reviewed branch's magi.toml")
8538        );
8539    }
8540
8541    #[tokio::test]
8542    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8543        let mut state = repair_state(false, true);
8544        state.reviews[0].clean = true;
8545        let mut finding = contested().findings.remove(0);
8546        finding.severity = crate::verdict::Severity::Minor;
8547        state.reviews[0].reviews[0].findings.push(finding);
8548        assert!(state.open_findings().is_empty());
8549        let mut pr = green_pr();
8550        pr.review_comments.clear();
8551        for language in ["en", "ja"] {
8552            state.config.graph.language = language.into();
8553            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8554            assert!(html.contains("[R3-1-1] minor"));
8555            assert!(html.contains(if language == "ja" {
8556                "内部指摘の解決を意味するものではありません"
8557            } else {
8558                "This does not mean internal findings are resolved"
8559            }));
8560            assert!(
8561                html.find(if language == "ja" {
8562                    "承認が必要な理由:"
8563                } else {
8564                    "Approval required because:"
8565                })
8566                .unwrap()
8567                    < html.find("[R3-1-1]").unwrap()
8568            );
8569        }
8570        state.config.graph.language = "en".into();
8571        let observed = seen("abc", Checks::Green, "CLEAN", false);
8572        assert_eq!(
8573            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8574                .await
8575                .unwrap(),
8576            ApprovalGate::Pending
8577        );
8578        let id = state.land_approval.as_ref().unwrap().question.clone();
8579        let q = ask::Questions::open().get(&id).unwrap();
8580        assert!(q.detail.starts_with("Approval required because:"));
8581        assert!(q.detail.contains("final reviewer reject"));
8582        assert!(q.detail.contains("[R3-1-1] minor"));
8583        assert!(
8584            q.detail
8585                .contains("No adoption or resolution evidence recorded")
8586        );
8587        state.config.graph.land_approval = false;
8588        state.config.graph.hold_contested_merge = false;
8589        state.reviews.clear();
8590        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8591        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8592        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8593        let forge = Scripted::new(vec![observed], vec![]);
8594        land_with(&mut state, URL, &forge).await.unwrap();
8595        assert!(!forge.calls().contains(&"merge"));
8596        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8597        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8598    }
8599
8600    #[tokio::test]
8601    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8602        let mut state = repair_state(false, true);
8603        state.config.graph.land_approval = false;
8604        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8605        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8606        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8607        state.reviews[0].reviews[0].summary = long_reason.clone();
8608        for i in 1..=10 {
8609            let mut finding = contested().findings.remove(0);
8610            finding.id = format!("R1-1-{i}");
8611            finding.severity = if i == 9 {
8612                crate::verdict::Severity::Major
8613            } else {
8614                crate::verdict::Severity::Minor
8615            };
8616            finding.title = long_title.clone();
8617            finding.detail = long_detail.clone();
8618            state.reviews[0].reviews[0].findings.push(finding);
8619        }
8620        state.reviews[0].fix = Some(crate::run::FixRecord {
8621            agent: "fixer".into(),
8622            addressed: vec!["R1-1-9".into()],
8623            rejected: vec![crate::verdict::Rejection {
8624                id: "R1-1-10".into(),
8625                why: long_reason.clone(),
8626            }],
8627            notes: long_detail.clone(),
8628            committed: false,
8629            failed: None,
8630            duration_ms: 0,
8631            continuation: None,
8632        });
8633        assert_eq!(
8634            approval_reasons(&state),
8635            [
8636                ApprovalReason::OpenBlockingFindings,
8637                ApprovalReason::FinalReject
8638            ]
8639        );
8640        let excerpt = approval_excerpt(&long_reason, 300);
8641        assert_eq!(excerpt.chars().count(), 300);
8642        assert!(excerpt.ends_with('…'));
8643        let boundary = "拒".repeat(300);
8644        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8645        for language in ["en", "ja"] {
8646            state.config.graph.language = language.into();
8647            for revote in [
8648                None,
8649                Some(crate::verdict::ReviewVote::Reject),
8650                Some(crate::verdict::ReviewVote::Approve),
8651            ] {
8652                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8653                state.reviews[0].reconsideration.clear();
8654                if let Some(vote) = revote {
8655                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8656                    state.reviews[0]
8657                        .reconsideration
8658                        .push(crate::run::ReviewRevoteRecord {
8659                            reviewer: 1,
8660                            agent: "alpha".into(),
8661                            vote: Some(vote),
8662                            reason: long_reason.clone(),
8663                            failed: None,
8664                        });
8665                }
8666                let evidence = approval_evidence(&state);
8667                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8668                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8669                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8670                assert_eq!(
8671                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8672                    final_vote == crate::verdict::ReviewVote::Reject
8673                );
8674                assert!(evidence.contains(&excerpt));
8675                assert!(evidence.contains("[R1-1-8] minor"));
8676                assert!(evidence.contains("[R1-1-9] major"));
8677                assert!(evidence.contains("[R1-1-10] minor"));
8678                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8679                    assert!(!evidence.contains(tail));
8680                }
8681                assert!(evidence.contains(if language == "ja" {
8682                    "採用申告(解決未確認)"
8683                } else {
8684                    "claimed addressed (resolution unconfirmed)"
8685                }));
8686                assert!(evidence.contains(if language == "ja" {
8687                    "不採用理由"
8688                } else {
8689                    "declined"
8690                }));
8691                let major_evidence = evidence
8692                    .split("[R1-1-9] major")
8693                    .nth(1)
8694                    .unwrap()
8695                    .split("[R1-1-10]")
8696                    .next()
8697                    .unwrap();
8698                assert!(major_evidence.contains(if language == "ja" {
8699                    "採用申告(解決未確認)"
8700                } else {
8701                    "claimed addressed (resolution unconfirmed)"
8702                }));
8703                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8704                assert!(last_evidence.contains(if language == "ja" {
8705                    "不採用理由"
8706                } else {
8707                    "declined"
8708                }));
8709                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8710                assert!(html.contains(&esc(&evidence)));
8711                assert_eq!(
8712                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8713                        .await
8714                        .unwrap(),
8715                    ApprovalGate::Pending
8716                );
8717                let id = &state.land_approval.take().unwrap().question;
8718                let q = ask::Questions::open().get(id).unwrap();
8719                assert!(q.detail.starts_with(&evidence));
8720            }
8721            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8722        }
8723    }
8724
8725    #[test]
8726    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8727        for (major, reject) in [(true, false), (false, true), (true, true)] {
8728            let state = repair_state(major, reject);
8729            assert!(repair_available(&state));
8730            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8731        }
8732        assert!(!repair_available(&repair_state(false, false)));
8733        let mut clean = repair_state(true, false);
8734        clean.reviews[0].clean = true;
8735        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8736        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8737    }
8738
8739    #[tokio::test]
8740    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8741        let mut state = repair_state(true, false);
8742        let pr = green_pr();
8743        approval_gate(&mut state, &pr, "feat: x", None, "abc")
8744            .await
8745            .unwrap();
8746        let store = ask::Questions::open();
8747        let id = state.land_approval.as_ref().unwrap().question.clone();
8748        // Simulate an unanswered two-choice question created by an older build.
8749        store
8750            .update(&id, |q| {
8751                q.choices = vec![APPROVE.into(), HOLD.into()];
8752                Ok(())
8753            })
8754            .unwrap();
8755        let upgraded = store.get(&id).unwrap();
8756        assert_eq!(upgraded.choices, approval_choices(&state));
8757        assert!(!upgraded.actions.contains_key(HOLD));
8758        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
8759        assert!(
8760            store
8761                .list()
8762                .iter()
8763                .any(|q| q.id == id && q.choices == upgraded.choices)
8764        );
8765        assert!(
8766            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
8767        );
8768        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
8769        store
8770            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
8771            .unwrap();
8772        // An earlier build may have recorded the repair answer and stopped
8773        // before any driver consumed it. Reconcile that durable decision too.
8774        store
8775            .update(&id, |q| {
8776                q.actions.clear();
8777                Ok(())
8778            })
8779            .unwrap();
8780        assert_eq!(
8781            store.get(&id).unwrap().chosen_action(),
8782            Some(&ask::ChoiceAction::Resume {
8783                run: state.id.clone()
8784            })
8785        );
8786        let binding = state.land_approval.clone();
8787        assert_eq!(
8788            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8789                .await
8790                .unwrap(),
8791            ApprovalGate::Repair
8792        );
8793        assert!(state.land_approval.is_none());
8794        assert_eq!(state.merge_repairs.len(), 1);
8795        let request = &state.merge_repairs[0];
8796        assert_eq!(request.question, id);
8797        assert_eq!(request.head, "abc");
8798        assert_eq!(request.pr, pr.url);
8799        assert_eq!(
8800            request.reviews[0].reviews[0].summary,
8801            "precise rejection reason"
8802        );
8803        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
8804        assert_eq!(
8805            request.reviews[0].reviews[0].findings[0].detail,
8806            state.reviews[0].reviews[0].findings[0].detail
8807        );
8808        // A repeated consumption of the same answer never dispatches another repair.
8809        state.land_approval = binding;
8810        assert_eq!(
8811            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8812                .await
8813                .unwrap(),
8814            ApprovalGate::Repair
8815        );
8816        assert_eq!(state.merge_repairs.len(), 1);
8817        state.land_approval = None;
8818        state.config.graph.land_approval = false;
8819        state.config.graph.hold_contested_merge = false;
8820        state.reviews.clear();
8821        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
8822        assert_eq!(
8823            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8824                .await
8825                .unwrap(),
8826            ApprovalGate::Pending
8827        );
8828        let fresh_id = &state.land_approval.as_ref().unwrap().question;
8829        assert_ne!(fresh_id, &id);
8830        let fresh = store.get(fresh_id).unwrap();
8831        assert!(
8832            fresh
8833                .detail
8834                .contains("fresh owner approval after merge repair")
8835        );
8836        assert!(fresh.detail.contains("No review recorded"));
8837        assert!(!fresh.detail.contains("precise rejection reason"));
8838    }
8839
8840    #[tokio::test]
8841    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
8842        for choice in [REPAIR, HOLD] {
8843            let mut state = repair_state(false, true);
8844            let observed = seen("abc", Checks::Green, "CLEAN", false);
8845            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
8846                .await
8847                .unwrap();
8848            let id = state.land_approval.as_ref().unwrap().question.clone();
8849            ask::Questions::open()
8850                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
8851                .unwrap();
8852            let forge = Scripted::new(vec![observed], vec![(true, "")]);
8853            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
8854                .await
8855                .unwrap();
8856            assert!(
8857                forge
8858                    .argvs()
8859                    .iter()
8860                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
8861            );
8862            assert!(!forge.calls().contains(&"fix"));
8863            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
8864            assert_ne!(state.status, RunStatus::Merged);
8865        }
8866    }
8867
8868    #[test]
8869    fn only_the_merge_choice_merges_and_silence_holds() {
8870        let table = [
8871            (None, Approval::Hold),
8872            (Some("merge"), Approval::Merge),
8873            (Some(" merge\n"), Approval::Merge),
8874            (Some("hold"), Approval::Hold),
8875            (Some(""), Approval::Hold),
8876            (Some("yes"), Approval::Hold),
8877        ];
8878        for (answer, want) in table {
8879            assert_eq!(
8880                approval(answer),
8881                want,
8882                "answer {answer:?} must resolve to {want:?}"
8883            );
8884        }
8885    }
8886
8887    #[tokio::test]
8888    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
8889        let mut state = landing_state();
8890        state.config.graph.land_approval = true;
8891        let pr = green_pr();
8892
8893        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8894            .await
8895            .unwrap();
8896        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
8897        assert!(
8898            !state.parked,
8899            "approval_gate itself never sets `parked`; only its caller does"
8900        );
8901
8902        let store = ask::Questions::open();
8903        let filed: Vec<_> = store
8904            .list()
8905            .into_iter()
8906            .filter(|q| q.run == state.id)
8907            .collect();
8908        assert_eq!(filed.len(), 1, "exactly one question is filed");
8909        assert_eq!(filed[0].node, APPROVAL_NODE);
8910        assert_eq!(filed[0].choices, approval_choices(&state));
8911        assert!(filed[0].status.open());
8912
8913        // A second visit - standing in for a resumed run whose slot the
8914        // daemon handed to something else while nobody had answered - must
8915        // find the same question rather than filing a second one.
8916        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
8917            .await
8918            .unwrap();
8919        assert_eq!(again, ApprovalGate::Pending);
8920        let still_one = store
8921            .list()
8922            .into_iter()
8923            .filter(|q| q.run == state.id)
8924            .count();
8925        assert_eq!(
8926            still_one, 1,
8927            "asking twice must not double-file the question"
8928        );
8929    }
8930
8931    #[tokio::test]
8932    async fn approving_the_existing_question_is_read_back_as_approved() {
8933        crate::run::pin_test_home();
8934        let mut state = run_state();
8935        state.config.graph.land_approval = true;
8936        let pr = green_pr();
8937        assert_eq!(
8938            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8939                .await
8940                .unwrap(),
8941            ApprovalGate::Pending
8942        );
8943
8944        let store = ask::Questions::open();
8945        let mut q = store
8946            .list()
8947            .into_iter()
8948            .find(|q| q.run == state.id)
8949            .expect("filed above");
8950        let talks = crate::talk::Talks::open();
8951        let cfg = crate::config::Config {
8952            agents: vec![crate::config::AgentSpec {
8953                id: "mock".into(),
8954                kind: crate::config::AgentKind::Command,
8955                model: None,
8956                command: vec!["true".into()],
8957                extra_args: Vec::new(),
8958                env: Default::default(),
8959                prompt_delivery: None,
8960                max_turn_secs: None,
8961            }],
8962            ..crate::config::Config::default()
8963        };
8964        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
8965        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
8966        crate::talk::drain(&mut talk, &talks).unwrap();
8967        assert_eq!(
8968            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8969                .await
8970                .unwrap(),
8971            ApprovalGate::Pending,
8972            "consultation cannot approve or hold the merge"
8973        );
8974        q = store.get(&q.id).unwrap();
8975        assert!(q.answer.is_none());
8976        // The owner's merge answer (from the chat or the card) is the approval.
8977        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
8978        store.put(&mut q).unwrap();
8979
8980        assert_eq!(
8981            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8982                .await
8983                .unwrap(),
8984            ApprovalGate::Approved
8985        );
8986    }
8987
8988    #[tokio::test]
8989    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
8990        crate::run::pin_test_home();
8991        let store = ask::Questions::open();
8992        for via_list in [false, true] {
8993            let mut state = repair_state(false, true);
8994            let pr = green_pr();
8995            approval_gate(&mut state, &pr, "feat: x", None, "abc")
8996                .await
8997                .unwrap();
8998            let id = state.land_approval.as_ref().unwrap().question.clone();
8999            store
9000                .update(&id, |q| {
9001                    q.actions.insert(
9002                        HOLD.to_owned(),
9003                        ask::ChoiceAction::Resume {
9004                            run: state.id.clone(),
9005                        },
9006                    );
9007                    Ok(())
9008                })
9009                .unwrap();
9010            let q = if via_list {
9011                store.list().into_iter().find(|q| q.id == id).unwrap()
9012            } else {
9013                store.get(&id).unwrap()
9014            };
9015            assert!(!q.actions.contains_key(HOLD));
9016            store
9017                .update(&id, |q| {
9018                    // Check the persisted migration, without another read upgrade.
9019                    assert!(!q.actions.contains_key(HOLD));
9020                    assert!(!upgrade_repair_choice(q, &state));
9021                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9022                })
9023                .unwrap();
9024            assert!(store.get(&id).unwrap().chosen_action().is_none());
9025            assert_eq!(
9026                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9027                    .await
9028                    .unwrap(),
9029                ApprovalGate::Held
9030            );
9031        }
9032    }
9033
9034    #[tokio::test]
9035    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9036        crate::run::pin_test_home();
9037        let store = ask::Questions::open();
9038
9039        let mut held_state = run_state();
9040        held_state.config.graph.land_approval = true;
9041        let pr = green_pr();
9042        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9043            .await
9044            .unwrap();
9045        let mut q = store
9046            .list()
9047            .into_iter()
9048            .find(|q| q.run == held_state.id)
9049            .expect("filed above");
9050        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9051        store.put(&mut q).unwrap();
9052        assert_eq!(
9053            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9054                .await
9055                .unwrap(),
9056            ApprovalGate::Held
9057        );
9058
9059        let mut abandoned_state = run_state();
9060        abandoned_state.config.graph.land_approval = true;
9061        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9062            .await
9063            .unwrap();
9064        let mut q = store
9065            .list()
9066            .into_iter()
9067            .find(|q| q.run == abandoned_state.id)
9068            .expect("filed above");
9069        q.abandon("no answer within the timeout");
9070        store.put(&mut q).unwrap();
9071        assert_eq!(
9072            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9073                .await
9074                .unwrap(),
9075            ApprovalGate::Pending,
9076            "an expired approval must ask again without merging"
9077        );
9078        assert_eq!(
9079            store.get(&q.id).unwrap().status,
9080            ask::QuestionStatus::Abandoned
9081        );
9082        assert_ne!(
9083            abandoned_state.land_approval.as_ref().unwrap().question,
9084            q.id
9085        );
9086    }
9087
9088    fn contested() -> ContestedHandoff {
9089        let finding = |id: &str, n: u32| crate::verdict::Finding {
9090            id: id.to_owned(),
9091            severity: crate::verdict::Severity::Major,
9092            file: Some("src/a.rs".to_owned()),
9093            line: Some(n),
9094            title: format!("problem {id}"),
9095            detail: String::new(),
9096        };
9097        ContestedHandoff {
9098            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9099            rejecters: vec![(1, "alpha".to_owned())],
9100        }
9101    }
9102
9103    #[test]
9104    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9105        let mut state = run_state();
9106        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9107        state.contested_handoff = Some(contested());
9108        assert!(contested_to_ask(&state).is_some());
9109        state.config.graph.hold_contested_merge = false;
9110        assert!(
9111            contested_to_ask(&state).is_none(),
9112            "the switch restores today"
9113        );
9114    }
9115
9116    #[test]
9117    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9118        let q = ask::Question::new(
9119            "run-1".to_owned(),
9120            APPROVAL_NODE.to_owned(),
9121            "land".to_owned(),
9122            "Merge?".to_owned(),
9123            String::new(),
9124            vec![APPROVE.to_owned(), HOLD.to_owned()],
9125        );
9126        let none = deputy_brief(&q, None);
9127        assert!(none.contains("could not be read"), "{none}");
9128        assert!(none.contains("Silence is a hold"), "{none}");
9129
9130        let mut state = run_state();
9131        state.pr = Some(crate::run::PrRecord {
9132            url: "https://example.test/pull/7".to_owned(),
9133            number: 7,
9134            state: "open".to_owned(),
9135            checks: "green".to_owned(),
9136            round: 0,
9137            rounds: 3,
9138            red_at_merge: Vec::new(),
9139        });
9140        state.contested_handoff = Some(contested());
9141        let b = deputy_brief(&q, Some(&state));
9142        assert!(b.contains("https://example.test/pull/7"), "{b}");
9143        assert!(b.contains("not recorded in this legacy question"), "{b}");
9144        assert!(!b.contains("This question was filed although"));
9145        state.contested_handoff = None;
9146        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9147    }
9148
9149    #[test]
9150    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9151        for lang in ["en", "ja"] {
9152            let mut cfg = crate::config::Config::default();
9153            cfg.graph.language = lang.to_owned();
9154            let w = words(&cfg.graph.language);
9155            let text = w.approval_detail(
9156                "https://github.com/yukimemi/magi/pull/42",
9157                "main",
9158                "feat: x",
9159                Some(&contested()),
9160            );
9161            assert!(text.contains("pull/42"), "{text}");
9162            assert!(
9163                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9164                "{text}"
9165            );
9166            assert!(text.contains("R3-1-5"), "{text}");
9167            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9168            assert!(text.contains("2"), "the rest are counted: {text}");
9169            assert!(text.contains("#1 (alpha)"), "{text}");
9170        }
9171        let plain = words("en").approval_detail("u", "main", "s", None);
9172        assert!(!plain.contains("reject"), "{plain}");
9173    }
9174
9175    #[tokio::test]
9176    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9177        crate::run::pin_test_home();
9178        let mut state = run_state();
9179        state.config.graph.land_approval = false;
9180        state.contested_handoff = Some(contested());
9181        let pr = green_pr();
9182        let c = contested_to_ask(&state);
9183        assert_eq!(
9184            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9185                .await
9186                .unwrap(),
9187            ApprovalGate::Pending,
9188            "silence is a hold"
9189        );
9190        let store = ask::Questions::open();
9191        let filed: Vec<_> = store
9192            .list()
9193            .into_iter()
9194            .filter(|q| q.run == state.id)
9195            .collect();
9196        assert_eq!(filed.len(), 1);
9197        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9198
9199        assert_eq!(
9200            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9201                .await
9202                .unwrap(),
9203            ApprovalGate::Pending
9204        );
9205        let mut q = store
9206            .list()
9207            .into_iter()
9208            .find(|q| q.run == state.id)
9209            .unwrap();
9210        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9211        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9212        store.put(&mut q).unwrap();
9213        assert_eq!(
9214            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9215                .await
9216                .unwrap(),
9217            ApprovalGate::Approved
9218        );
9219    }
9220
9221    #[test]
9222    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9223        let rows = parse_numstat(NUMSTAT);
9224        assert_eq!(
9225            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9226            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9227        );
9228        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9229    }
9230    #[test]
9231    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9232        // Reported from a real run: the merge question arrived in English on a
9233        // repository with `language = "ja"`. magi's own strings have to follow
9234        // that setting too - "it is a literal in Rust" is not an answer.
9235        let mut state = run_state();
9236        state.config.graph.language = "ja".to_owned();
9237        let pr = green_pr();
9238        let commits = ["c1".to_owned()];
9239
9240        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9241        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9242        assert!(ja.contains("squash されるコミット"), "{ja}");
9243        assert!(ja.contains("レビューコメント"), "{ja}");
9244        assert!(ja.contains("差分"), "{ja}");
9245        assert!(
9246            !ja.contains("Commits being squashed"),
9247            "no English left over"
9248        );
9249
9250        let w = words("ja");
9251        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9252        assert!(
9253            w.approval_detail("http://x/1", "main", "feat: x", None)
9254                .contains("パネル")
9255        );
9256
9257        // The evidence itself is language-neutral and must survive either way.
9258        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9259        assert!(ja.contains("feat: x"), "nor is the merge subject");
9260
9261        // English stays the default, and a language magi cannot check falls
9262        // back to it rather than shipping a guess.
9263        state.config.graph.language = "en".to_owned();
9264        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9265        assert!(en.contains("Commits being squashed"), "{en}");
9266        assert_eq!(words("Klingon").html_lang, "en");
9267    }
9268
9269    /// A `gh pr list` result naming exactly one pull request whose base and
9270    /// merge time both fit the run is exactly the case
9271    /// [`find_external_merge`] exists to act on.
9272    #[test]
9273    fn pick_open_pr_classifies_by_count_and_base() {
9274        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9275        assert_eq!(
9276            pick_open_pr(one, "main").unwrap(),
9277            OpenPr::One {
9278                url: "https://x/pull/58".into(),
9279                title: "t".into()
9280            }
9281        );
9282        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9283        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9284        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9285                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9286        assert_eq!(
9287            pick_open_pr(two, "main").unwrap(),
9288            OpenPr::Many(vec!["u1".into(), "u2".into()])
9289        );
9290        // A fork's same-named branch is not a pull request for this head.
9291        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9292        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9293        assert!(pick_open_pr("not json", "main").is_err());
9294        // An incomplete record is an error, never "nothing open".
9295        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9296        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9297    }
9298
9299    #[test]
9300    fn pick_merged_pr_picks_the_unique_match() {
9301        let json = r#"[
9302            {"url": "https://github.com/o/r/pull/42", "number": 42,
9303             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9304        ]"#;
9305        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9306        let found = pick_merged_pr(json, "main", created_at)
9307            .expect("valid json")
9308            .expect("one unambiguous match");
9309        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9310        assert_eq!(found.number, 42);
9311    }
9312
9313    /// Two candidates surviving the filter is exactly as uninformative as
9314    /// zero — a branch name can be reused across runs — so neither is
9315    /// preferred over the other and nothing is recorded automatically.
9316    #[test]
9317    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9318        let json = r#"[
9319            {"url": "https://github.com/o/r/pull/42", "number": 42,
9320             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9321            {"url": "https://github.com/o/r/pull/43", "number": 43,
9322             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9323        ]"#;
9324        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9325        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9326    }
9327
9328    /// A pull request that targets a different base branch cannot be this
9329    /// run's, whatever its head branch is named — a reused branch name from
9330    /// an unrelated task must not be recorded as this run's merge.
9331    #[test]
9332    fn pick_merged_pr_ignores_a_different_base_branch() {
9333        let json = r#"[
9334            {"url": "https://github.com/o/r/pull/42", "number": 42,
9335             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9336        ]"#;
9337        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9338        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9339    }
9340
9341    /// A pull request merged before this run was even created cannot be this
9342    /// run's winner, no matter how its head branch is spelled.
9343    #[test]
9344    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9345        let json = r#"[
9346            {"url": "https://github.com/o/r/pull/42", "number": 42,
9347             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9348        ]"#;
9349        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9350        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9351    }
9352
9353    #[test]
9354    fn slug_of_pr_url_reads_host_owner_and_repo() {
9355        assert_eq!(
9356            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9357            Some("github.com/yukimemi/shun")
9358        );
9359    }
9360
9361    #[test]
9362    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9363        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9364        assert_eq!(slug_of_pr_url("not a url at all"), None);
9365        assert_eq!(slug_of_pr_url("https://github.com"), None);
9366    }
9367
9368    #[test]
9369    fn slug_of_repo_url_reads_host_owner_and_repo() {
9370        assert_eq!(
9371            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9372            Some("github.com/yukimemi/magi")
9373        );
9374        assert_eq!(slug_of_repo_url("https://github.com"), None);
9375    }
9376
9377    #[test]
9378    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9379        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9380            .expect("same repo, different case");
9381    }
9382
9383    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9384    /// own in-progress run and rewrote its status from a pull request in a
9385    /// completely different repository. This is the guard that must catch
9386    /// that even when an explicit (but wrong) id is given.
9387    #[test]
9388    fn ensure_same_repo_refuses_a_different_repo() {
9389        let err =
9390            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9391        let msg = format!("{err:#}");
9392        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9393        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9394    }
9395
9396    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9397    /// instance mirroring a `github.com` repository's name, say) must not be
9398    /// treated as the same repository just because the trailing path
9399    /// matches.
9400    #[test]
9401    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9402        let err = ensure_same_repo(
9403            "github.com/yukimemi/magi",
9404            "github.example.com/yukimemi/magi",
9405        )
9406        .unwrap_err();
9407        let msg = format!("{err:#}");
9408        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9409        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9410    }
9411
9412    /// No winner decided yet means there is no branch to ask GitHub about at
9413    /// all — `find_external_merge` must return `None` without ever spawning
9414    /// `gh`, which this proves by never providing a real repository to spawn
9415    /// it in.
9416    #[tokio::test]
9417    async fn find_external_merge_returns_none_without_a_winner() {
9418        let state = RunState::new(
9419            PathBuf::from("/no/such/repo"),
9420            "main".to_owned(),
9421            "0000000000000000000000000000000000000000".to_owned(),
9422            "irrelevant".to_owned(),
9423            crate::config::Config::default(),
9424        );
9425        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9426    }
9427
9428    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9429        let mut run = RunState::new(
9430            PathBuf::from(repo),
9431            "main".to_owned(),
9432            "abcdef1234".to_owned(),
9433            "x".to_owned(),
9434            crate::config::Config::default(),
9435        );
9436        run.id = id.to_owned();
9437        run.status = status;
9438        run.driver_exited = true;
9439        run.pr = Some(crate::run::PrRecord {
9440            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9441            url: url.to_owned(),
9442            state: state.to_owned(),
9443            checks: "red".to_owned(),
9444            round: 0,
9445            rounds: 2,
9446            red_at_merge: Vec::new(),
9447        });
9448        run.save_under(home).unwrap();
9449    }
9450
9451    fn recorded(home: &Path, id: &str) -> String {
9452        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9453        serde_json::from_str::<RunState>(&body)
9454            .unwrap()
9455            .pr
9456            .unwrap()
9457            .state
9458    }
9459
9460    const PR: &str = "https://github.com/o/r/pull/7";
9461
9462    #[test]
9463    fn write_through_updates_predecessors_and_siblings_only() {
9464        let tmp = tempfile::tempdir().unwrap();
9465        let h = tmp.path();
9466        pr_run(
9467            h,
9468            "20261004-100000-aaaa",
9469            "/repo/r",
9470            RunStatus::Superseded,
9471            PR,
9472            "open",
9473        );
9474        pr_run(
9475            h,
9476            "20261004-100100-bbbb",
9477            "/repo/r",
9478            RunStatus::Blocked,
9479            PR,
9480            "open",
9481        );
9482        // Not terminal: a driver may be writing it.
9483        pr_run(
9484            h,
9485            "20261004-100200-cccc",
9486            "/repo/r",
9487            RunStatus::Landing,
9488            PR,
9489            "open",
9490        );
9491        // Another repository's pull request with the same number.
9492        pr_run(
9493            h,
9494            "20261004-100300-dddd",
9495            "/repo/other",
9496            RunStatus::Blocked,
9497            "https://github.com/o/other/pull/7",
9498            "open",
9499        );
9500        // A different pull request of the same repository.
9501        pr_run(
9502            h,
9503            "20261004-100400-eeee",
9504            "/repo/r",
9505            RunStatus::Blocked,
9506            "https://github.com/o/r/pull/8",
9507            "open",
9508        );
9509        pr_run(
9510            h,
9511            "20261004-100500-ffff",
9512            "/repo/r",
9513            RunStatus::Merged,
9514            PR,
9515            "open",
9516        );
9517        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9518
9519        assert_eq!(
9520            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9521            2
9522        );
9523        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9524        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9525        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9526        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9527        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9528        // The source's own record is the caller's to write.
9529        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9530        // Idempotent.
9531        assert_eq!(
9532            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9533            0
9534        );
9535        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9536        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9537    }
9538
9539    #[test]
9540    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9541        let home = tempfile::tempdir().unwrap();
9542        let live_id = "20261004-100000-aaaa";
9543        let unknown_id = "20261004-100100-bbbb";
9544        for id in [live_id, unknown_id] {
9545            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9546            let mut s = RunState::load_under(id, home.path()).unwrap();
9547            s.driver_exited = false;
9548            // Model legacy driver evidence, not a newly reserved unstarted run.
9549            s.driver_claimed = None;
9550            if id == live_id {
9551                s.driver_pid = Some(std::process::id());
9552                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9553            }
9554            s.save_under(home.path()).unwrap();
9555            if id == unknown_id {
9556                assert_eq!(
9557                    s.liveness_under(home.path(), false),
9558                    crate::run::Liveness::Unknown
9559                );
9560            }
9561        }
9562        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9563        assert_eq!(apply_pr_states(home.path(), &known), 0);
9564        for id in [live_id, unknown_id] {
9565            assert_eq!(recorded(home.path(), id), "open");
9566        }
9567    }
9568
9569    #[test]
9570    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9571        let tmp = tempfile::tempdir().unwrap();
9572        let h = tmp.path();
9573        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9574        pr_run(
9575            h,
9576            "20261004-100000-aaaa",
9577            "/repo/r",
9578            RunStatus::Superseded,
9579            &url(1),
9580            "open",
9581        );
9582        pr_run(
9583            h,
9584            "20261004-100100-bbbb",
9585            "/repo/r",
9586            RunStatus::Blocked,
9587            &url(2),
9588            "open",
9589        );
9590        pr_run(
9591            h,
9592            "20261004-100200-cccc",
9593            "/repo/r",
9594            RunStatus::Ready,
9595            &url(3),
9596            "open",
9597        );
9598        pr_run(
9599            h,
9600            "20261004-100300-dddd",
9601            "/repo/r",
9602            RunStatus::Ready,
9603            &url(4),
9604            "open",
9605        );
9606        pr_run(
9607            h,
9608            "20261004-100400-eeee",
9609            "/repo/r",
9610            RunStatus::Implementing,
9611            &url(1),
9612            "open",
9613        );
9614        assert_eq!(stale_open_prs(h).len(), 4);
9615
9616        let mut known = BTreeMap::new();
9617        known.insert(url(1), PrLifecycle::Merged);
9618        known.insert(url(2), PrLifecycle::Closed);
9619        known.insert(url(3), PrLifecycle::Open);
9620        // #4: the forge could not be read, so it has no answer.
9621        assert_eq!(apply_pr_states(h, &known), 2);
9622        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9623        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9624        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9625        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9626        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9627        assert_eq!(apply_pr_states(h, &known), 0);
9628    }
9629
9630    // --- the land loop against a scripted forge -------------------------
9631
9632    use std::collections::VecDeque;
9633    use std::sync::Mutex;
9634
9635    /// Answers views from a script (the last one repeats), merges from a
9636    /// queue, and records every call so a test can assert the order.
9637    struct Scripted {
9638        evidence: Mutex<Option<CiEvidence>>,
9639        views: Mutex<VecDeque<Seen>>,
9640        merges: Mutex<VecDeque<(bool, String)>>,
9641        fix: Mutex<Option<Fixed>>,
9642        log: Mutex<Vec<&'static str>>,
9643        argvs: Mutex<Vec<Vec<String>>>,
9644        required: Mutex<Option<BTreeSet<String>>>,
9645        /// Set once a merge answered ok: the forge then reports `merged`,
9646        /// unless `queued` says the merge only entered a queue.
9647        merged: Mutex<bool>,
9648        queued: Mutex<bool>,
9649        /// Views fail once a merge answered ok.
9650        unreadable_after_merge: Mutex<bool>,
9651        /// Workflow runs asked to rerun, and whether that call fails.
9652        reruns: Mutex<Vec<String>>,
9653        rerun_fails: Mutex<bool>,
9654        /// Unix seconds; every poll moves it on by `POLL`.
9655        clock: Mutex<i64>,
9656    }
9657
9658    impl Scripted {
9659        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9660            Self {
9661                evidence: Mutex::new(None),
9662                views: Mutex::new(views.into()),
9663                merges: Mutex::new(
9664                    merges
9665                        .into_iter()
9666                        .map(|(ok, m)| (ok, m.to_owned()))
9667                        .collect(),
9668                ),
9669                fix: Mutex::new(None),
9670                log: Mutex::new(Vec::new()),
9671                argvs: Mutex::new(Vec::new()),
9672                required: Mutex::new(None),
9673                merged: Mutex::new(false),
9674                queued: Mutex::new(false),
9675                unreadable_after_merge: Mutex::new(false),
9676                reruns: Mutex::new(Vec::new()),
9677                rerun_fails: Mutex::new(false),
9678                clock: Mutex::new(1_000),
9679            }
9680        }
9681        fn argvs(&self) -> Vec<Vec<String>> {
9682            self.argvs.lock().unwrap().clone()
9683        }
9684        fn calls(&self) -> Vec<&'static str> {
9685            self.log.lock().unwrap().clone()
9686        }
9687    }
9688
9689    impl Forge for Scripted {
9690        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9691            self.evidence.lock().unwrap().clone()
9692        }
9693        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9694            self.log.lock().unwrap().push("view");
9695            if *self.unreadable_after_merge.lock().unwrap()
9696                && !self.argvs.lock().unwrap().is_empty()
9697            {
9698                anyhow::bail!("forge unreachable");
9699            }
9700            let mut v = self.views.lock().unwrap();
9701            let mut seen = if v.len() > 1 {
9702                v.pop_front().unwrap()
9703            } else {
9704                v[0].clone()
9705            };
9706            if *self.merged.lock().unwrap() {
9707                seen.pr.state = PrLifecycle::Merged;
9708            }
9709            Ok(seen)
9710        }
9711        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9712            self.log.lock().unwrap().push("merge");
9713            self.argvs.lock().unwrap().push(argv.to_vec());
9714            let out = self
9715                .merges
9716                .lock()
9717                .unwrap()
9718                .pop_front()
9719                .expect("unscripted merge");
9720            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9721            {
9722                *self.merged.lock().unwrap() = true;
9723            }
9724            Ok(out)
9725        }
9726        async fn poll(&self) {
9727            self.log.lock().unwrap().push("poll");
9728            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9729        }
9730        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9731            self.log.lock().unwrap().push("rerun");
9732            self.reruns.lock().unwrap().push(run.to_owned());
9733            if *self.rerun_fails.lock().unwrap() {
9734                anyhow::bail!("gh run rerun: no");
9735            }
9736            Ok(())
9737        }
9738        fn now(&self) -> i64 {
9739            *self.clock.lock().unwrap()
9740        }
9741        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9742            self.required.lock().unwrap().clone()
9743        }
9744        async fn fix(
9745            &self,
9746            _state: &mut RunState,
9747            _pr: &PrState,
9748            _round: usize,
9749            _budget: usize,
9750            _reason: &str,
9751            _logs: &str,
9752        ) -> Result<Fixed> {
9753            self.log.lock().unwrap().push("fix");
9754            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
9755        }
9756    }
9757
9758    const REFUSED: &str =
9759        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
9760
9761    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
9762        let mut pr = green_pr();
9763        pr.checks = checks;
9764        pr.blocking = Blocking::of(merge_state);
9765        if !comments {
9766            pr.review_comments.clear();
9767        }
9768        Seen {
9769            pr,
9770            title: "feat: x".to_owned(),
9771            failing_urls: Vec::new(),
9772            head: head.to_owned(),
9773            rollup_head: head.to_owned(),
9774            merge_state: merge_state.to_owned(),
9775            contexts: Vec::new(),
9776            base: "main".to_owned(),
9777        }
9778    }
9779
9780    fn landing_state() -> RunState {
9781        crate::run::pin_test_home();
9782        let mut state = run_state();
9783        state.config.graph.land_approval = false;
9784        state
9785    }
9786
9787    #[test]
9788    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
9789        assert!(!awaiting_new_head(None, "aaa"));
9790        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
9791        assert!(awaiting_new_head(Some("abc123"), "def456"));
9792        assert!(awaiting_new_head(Some("abc123"), ""));
9793    }
9794
9795    #[test]
9796    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
9797        let open = |c, m: &str| seen("a", c, m, false);
9798        let table = [
9799            (None, false, Refused::Pending),
9800            (
9801                Some(open(Checks::Pending, "BLOCKED")),
9802                false,
9803                Refused::Pending,
9804            ),
9805            (
9806                Some(open(Checks::Unknown, "BLOCKED")),
9807                false,
9808                Refused::Pending,
9809            ),
9810            (
9811                Some(open(Checks::Green, "UNKNOWN")),
9812                false,
9813                Refused::Pending,
9814            ),
9815            (Some(open(Checks::Green, "")), false, Refused::Pending),
9816            (
9817                Some(open(Checks::Green, "BLOCKED")),
9818                false,
9819                Refused::Recheck,
9820            ),
9821            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
9822        ];
9823        for (after, rechecked, want) in table {
9824            assert_eq!(
9825                classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
9826                want
9827            );
9828        }
9829        let mut closed = open(Checks::Green, "CLEAN");
9830        closed.pr.state = PrLifecycle::Closed;
9831        assert_eq!(
9832            classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
9833            Refused::Final
9834        );
9835    }
9836
9837    #[tokio::test]
9838    async fn a_normal_landing_merges_on_the_first_look() {
9839        let mut state = landing_state();
9840        let forge = Scripted::new(
9841            vec![seen("a", Checks::Green, "CLEAN", false)],
9842            vec![(true, "")],
9843        );
9844        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9845            .await
9846            .unwrap();
9847        // One fresh read, then the head-bound merge.
9848        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
9849        assert_eq!(state.status, RunStatus::Merged);
9850    }
9851
9852    #[tokio::test]
9853    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
9854        let mut state = landing_state();
9855        let forge = Scripted::new(
9856            vec![seen("a", Checks::Green, "CLEAN", false)],
9857            std::iter::repeat_n((true, ""), 100).collect(),
9858        );
9859        *forge.queued.lock().unwrap() = true;
9860        let task = async {
9861            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9862                .await
9863                .unwrap();
9864        };
9865        // Still open after the command succeeded: it keeps watching and
9866        // never records a merge (it stops at the wait ceiling instead).
9867        task.await;
9868        assert_ne!(state.status, RunStatus::Merged);
9869    }
9870
9871    #[tokio::test]
9872    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
9873        let mut state = landing_state();
9874        let forge = Scripted::new(
9875            vec![seen("a", Checks::Green, "CLEAN", false)],
9876            std::iter::repeat_n((true, ""), 100).collect(),
9877        );
9878        *forge.unreadable_after_merge.lock().unwrap() = true;
9879        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9880            .await
9881            .ok();
9882        assert_ne!(state.status, RunStatus::Merged);
9883    }
9884
9885    #[tokio::test]
9886    async fn a_task_public_head_is_awaited_on_land_entry() {
9887        let mut state = landing_state();
9888        state.pr_head = Some("magi/task-abcd-1234".into());
9889        state.pr_pushed_head = Some("new".into());
9890        let forge = Scripted::new(
9891            vec![
9892                seen("old", Checks::Green, "CLEAN", false),
9893                seen("new", Checks::Green, "CLEAN", false),
9894            ],
9895            vec![(true, "")],
9896        );
9897        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9898            .await
9899            .unwrap();
9900        assert_eq!(
9901            forge.calls(),
9902            ["view", "poll", "view", "view", "merge", "view"]
9903        );
9904        assert_eq!(state.status, RunStatus::Merged);
9905    }
9906
9907    #[tokio::test]
9908    async fn a_superseded_public_head_owner_cannot_reenter_land() {
9909        let mut state = landing_state();
9910        state.pr_taken_over_by = Some("next".into());
9911        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
9912        assert!(
9913            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9914                .await
9915                .is_err()
9916        );
9917        assert!(forge.calls().is_empty());
9918    }
9919
9920    #[tokio::test]
9921    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
9922        let mut state = landing_state();
9923        let forge = Scripted::new(
9924            vec![
9925                seen("old", Checks::Green, "CLEAN", true),
9926                // The forge has not moved to the new head yet: still green.
9927                seen("old", Checks::Green, "CLEAN", true),
9928                seen("new", Checks::Pending, "BLOCKED", true),
9929                seen("new", Checks::Green, "CLEAN", true),
9930            ],
9931            vec![(true, "")],
9932        );
9933        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9934            head: "NEW".to_owned(),
9935        });
9936        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9937            .await
9938            .unwrap();
9939        assert_eq!(
9940            forge.calls(),
9941            [
9942                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
9943                "view"
9944            ]
9945        );
9946        assert_eq!(state.status, RunStatus::Merged);
9947    }
9948
9949    #[tokio::test]
9950    async fn a_head_that_never_arrives_stops_naming_both_commits() {
9951        let mut state = landing_state();
9952        let forge = Scripted::new(
9953            vec![
9954                seen("old", Checks::Green, "CLEAN", true),
9955                seen("someone-elses", Checks::Green, "CLEAN", true),
9956            ],
9957            vec![],
9958        );
9959        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
9960            head: "mine".to_owned(),
9961        });
9962        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9963            .await
9964            .unwrap();
9965        assert!(!forge.calls().contains(&"merge"));
9966        let why = state.merge.as_ref().unwrap().detail.clone();
9967        assert!(
9968            why.contains("mine") && why.contains("someone-elses"),
9969            "{why}"
9970        );
9971        assert_eq!(state.status, RunStatus::Blocked);
9972    }
9973
9974    #[tokio::test]
9975    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
9976        let mut state = landing_state();
9977        let forge = Scripted::new(
9978            vec![
9979                seen("a", Checks::Green, "CLEAN", false),
9980                seen("a", Checks::Green, "CLEAN", false),
9981                seen("a", Checks::Pending, "BLOCKED", false),
9982                seen("a", Checks::Pending, "BLOCKED", false),
9983                seen("a", Checks::Green, "CLEAN", false),
9984            ],
9985            vec![(false, REFUSED), (true, "")],
9986        );
9987        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9988            .await
9989            .unwrap();
9990        assert_eq!(
9991            forge.calls(),
9992            [
9993                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
9994                "view"
9995            ]
9996        );
9997        assert_eq!(state.status, RunStatus::Merged);
9998    }
9999
10000    #[tokio::test]
10001    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10002        let mut state = landing_state();
10003        let forge = Scripted::new(
10004            vec![
10005                seen("a", Checks::Green, "CLEAN", false),
10006                seen("a", Checks::Green, "BLOCKED", false),
10007            ],
10008            vec![(false, REFUSED), (false, REFUSED)],
10009        );
10010        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10011            .await
10012            .unwrap();
10013        // One re-look is allowed for the forge's own lag, then it is final.
10014        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10015        let why = state.merge.as_ref().unwrap().detail.clone();
10016        assert!(
10017            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10018            "{why}"
10019        );
10020        assert_eq!(state.status, RunStatus::Blocked);
10021    }
10022
10023    #[test]
10024    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10025        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10026        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10027        // The pull request is still on the commit before the push.
10028        assert_eq!(bound_head("old", "old", Some("new")), None);
10029        // The checks are the previous commit's.
10030        assert_eq!(bound_head("new", "old", Some("new")), None);
10031        assert_eq!(bound_head("new", "old", None), None);
10032        // Nothing readable.
10033        assert_eq!(bound_head("", "", None), None);
10034        assert_eq!(bound_head("", "", Some("new")), None);
10035        assert_eq!(bound_head("abc", "", None), None);
10036    }
10037
10038    fn view_json(head: &str) -> String {
10039        format!(
10040            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10041            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10042            "reviews":[],"comments":[]}}"#
10043        )
10044    }
10045
10046    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10047        format!(
10048            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10049            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10050            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10051            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10052        )
10053    }
10054
10055    #[test]
10056    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10057        // The view already points at the new head, but the node still answers
10058        // for the old commit with its red check: the head stays unbound.
10059        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10060        assert_eq!(s.rollup_head, "old");
10061        assert_eq!(s.pr.checks, Checks::Red);
10062        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10063        assert_eq!(s.failing_urls.len(), 1);
10064    }
10065
10066    #[test]
10067    fn checks_come_from_the_node_not_the_view() {
10068        let view = view_json("new").replace(
10069            r#""reviews""#,
10070            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10071        );
10072        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10073        assert_eq!(s.pr.checks, Checks::Green);
10074        assert!(s.pr.failing.is_empty());
10075        assert_eq!(
10076            bound_head(&s.head, &s.rollup_head, Some("new")),
10077            Some("new")
10078        );
10079    }
10080
10081    #[test]
10082    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10083        for node in [
10084            None,
10085            Some("not json".to_owned()),
10086            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10087            Some(node_json("new", "SUCCESS", true)),
10088        ] {
10089            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10090            assert!(s.rollup_head.is_empty());
10091            assert_eq!(s.pr.checks, Checks::Unknown);
10092            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10093        }
10094    }
10095
10096    #[test]
10097    fn the_merge_command_is_pinned_to_the_observed_head() {
10098        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10099        let at = argv
10100            .iter()
10101            .position(|a| a == "--match-head-commit")
10102            .unwrap();
10103        assert_eq!(argv[at + 1], "deadbeef");
10104    }
10105
10106    #[tokio::test]
10107    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10108        let mut state = landing_state();
10109        // The pull request is on the pushed head but the rollup is still the
10110        // previous commit's red, non-required result.
10111        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10112        stale.rollup_head = "old".to_owned();
10113        let forge = Scripted::new(
10114            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10115            vec![],
10116        );
10117        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10118            head: "new".to_owned(),
10119        });
10120        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10121            .await
10122            .unwrap();
10123        assert!(!forge.calls().contains(&"merge"));
10124        assert_eq!(state.status, RunStatus::Blocked);
10125        let why = state.merge.as_ref().unwrap().detail.clone();
10126        assert!(why.contains("new") && why.contains("old"), "{why}");
10127    }
10128
10129    #[test]
10130    fn a_refusal_read_against_another_commits_checks_is_pending() {
10131        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10132        after.rollup_head = "old".to_owned();
10133        assert_eq!(
10134            classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10135            Refused::Pending
10136        );
10137    }
10138
10139    #[tokio::test]
10140    async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10141        let mut state = landing_state();
10142        state.config.merge.ci = crate::config::MergeCi::None;
10143        state.config.graph.land_approval = true;
10144        let forge = Scripted::new(
10145            vec![seen("a", Checks::Unknown, "CLEAN", false)],
10146            vec![(true, "")],
10147        );
10148        land_with(&mut state, URL, &forge).await.unwrap();
10149        assert!(state.parked);
10150        assert_eq!(forge.calls(), ["view"]);
10151        let store = ask::Questions::open();
10152        let mut q = store
10153            .get(&state.land_approval.as_ref().unwrap().question)
10154            .unwrap();
10155        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10156        store.put(&mut q).unwrap();
10157
10158        land_with(&mut state, URL, &forge).await.unwrap();
10159        assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10160        assert_eq!(state.status, RunStatus::Merged);
10161        assert_eq!(
10162            value_of(&forge.argvs()[0], "--match-head-commit"),
10163            Some("a")
10164        );
10165    }
10166
10167    #[tokio::test]
10168    async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10169        let mut state = landing_state();
10170        state.config.merge.ci = crate::config::MergeCi::None;
10171        let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10172        land_with(&mut state, URL, &forge).await.unwrap();
10173        assert!(forge.reruns.lock().unwrap().is_empty());
10174        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10175        assert_eq!(state.status, RunStatus::Merged);
10176    }
10177
10178    #[test]
10179    fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10180        let shown = BTreeSet::new();
10181        let guard = |s: Option<&Seen>| {
10182            direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10183        };
10184        let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10185        assert!(guard(Some(&fresh)));
10186        assert!(!guard(None));
10187        fresh.rollup_head = "old".to_owned();
10188        assert!(!guard(Some(&fresh)));
10189        fresh.rollup_head = "a".to_owned();
10190        fresh.head = "b".to_owned();
10191        assert!(!guard(Some(&fresh)));
10192        assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10193        assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10194    }
10195
10196    #[test]
10197    fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10198        for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10199            let after = seen("a", checks, "BLOCKED", false);
10200            assert_eq!(
10201                classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10202                Refused::Recheck
10203            );
10204            assert_eq!(
10205                classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10206                Refused::Final
10207            );
10208        }
10209    }
10210
10211    #[tokio::test]
10212    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10213        let mut state = landing_state();
10214        let forge = Scripted::new(
10215            vec![seen("a", Checks::Red, "CLEAN", false)],
10216            vec![(true, "")],
10217        );
10218        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10219            .await
10220            .unwrap();
10221        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10222        assert_eq!(state.status, RunStatus::Merged);
10223    }
10224
10225    #[tokio::test]
10226    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10227        let mut state = landing_state();
10228        let forge = Scripted::new(
10229            vec![
10230                seen("a", Checks::Green, "CLEAN", false),
10231                seen("a", Checks::Green, "CLEAN", false),
10232                // Re-viewed after the refusal: someone pushed.
10233                seen("b", Checks::Green, "BLOCKED", false),
10234                seen("b", Checks::Green, "CLEAN", false),
10235            ],
10236            vec![(false, REFUSED), (true, "")],
10237        );
10238        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10239            .await
10240            .unwrap();
10241        assert_eq!(
10242            forge.calls(),
10243            [
10244                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10245            ]
10246        );
10247        assert_eq!(state.status, RunStatus::Merged);
10248    }
10249
10250    fn merged_view(head: &str) -> Seen {
10251        let mut m = seen(head, Checks::Green, "CLEAN", false);
10252        m.pr.state = PrLifecycle::Merged;
10253        m
10254    }
10255
10256    fn has(argv: &[String], flag: &str) -> bool {
10257        argv.iter().any(|a| a == flag)
10258    }
10259
10260    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10261        let at = argv.iter().position(|a| a == flag)?;
10262        argv.get(at + 1).map(String::as_str)
10263    }
10264
10265    const URL: &str = "https://github.com/o/r/pull/42";
10266
10267    #[tokio::test]
10268    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10269        let mut state = landing_state();
10270        let forge = Scripted::new(
10271            vec![
10272                seen("abc", Checks::Green, "CLEAN", false),
10273                seen("abc", Checks::Green, "CLEAN", false),
10274            ],
10275            vec![(true, "")],
10276        );
10277        land_with(&mut state, URL, &forge).await.unwrap();
10278        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10279        let argv = &forge.argvs()[0];
10280        assert!(has(argv, "--squash") && has(argv, "--subject"));
10281        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10282        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10283        assert_eq!(state.status, RunStatus::Merged);
10284        assert!(state.land_armed_head.is_none());
10285    }
10286
10287    #[tokio::test]
10288    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10289        let mut state = landing_state();
10290        state.land_armed_head = Some("a".to_owned());
10291        let forge = Scripted::new(
10292            vec![seen("a", Checks::Green, "CLEAN", false)],
10293            vec![(true, ""), (true, "")],
10294        );
10295        land_with(&mut state, URL, &forge).await.unwrap();
10296        let argvs = forge.argvs();
10297        assert!(has(&argvs[0], "--disable-auto"));
10298        assert!(!has(&argvs[1], "--auto"));
10299        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10300        assert_eq!(state.status, RunStatus::Merged);
10301        assert!(state.land_armed_head.is_none());
10302    }
10303
10304    #[tokio::test]
10305    async fn an_approval_never_carries_over_to_a_new_head() {
10306        crate::run::pin_test_home();
10307        let mut state = run_state();
10308        state.config.graph.land_approval = true;
10309        let pr = green_pr();
10310        let store = ask::Questions::open();
10311
10312        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10313            .await
10314            .unwrap();
10315        let mut q = store
10316            .list()
10317            .into_iter()
10318            .find(|q| q.run == state.id)
10319            .unwrap();
10320        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10321        store.put(&mut q).unwrap();
10322        assert_eq!(
10323            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10324                .await
10325                .unwrap(),
10326            ApprovalGate::Approved,
10327            "the same head keeps its approval"
10328        );
10329
10330        // A new head is a new question, not the old word.
10331        assert_eq!(
10332            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10333                .await
10334                .unwrap(),
10335            ApprovalGate::Pending
10336        );
10337        let all: Vec<_> = store
10338            .list()
10339            .into_iter()
10340            .filter(|q| q.run == state.id)
10341            .collect();
10342        assert_eq!(all.len(), 2);
10343
10344        // A question recorded before heads were tracked is not reused either.
10345        state.land_approval = None;
10346        assert_eq!(
10347            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10348                .await
10349                .unwrap(),
10350            ApprovalGate::Pending
10351        );
10352        let open = store
10353            .list()
10354            .into_iter()
10355            .filter(|q| q.run == state.id && q.status.open())
10356            .count();
10357        assert_eq!(open, 1, "the superseded question was retired");
10358    }
10359
10360    #[tokio::test]
10361    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10362        let mut state = landing_state();
10363        let forge = Scripted::new(
10364            vec![
10365                seen("a", Checks::Green, "CLEAN", false),
10366                // The fresh read before the merge: someone pushed.
10367                seen("b", Checks::Green, "CLEAN", false),
10368            ],
10369            vec![(true, "")],
10370        );
10371        land_with(&mut state, URL, &forge).await.unwrap();
10372        let argvs = forge.argvs();
10373        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10374        assert!(!has(&argvs[0], "--auto"));
10375        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10376        assert_eq!(state.status, RunStatus::Merged);
10377    }
10378
10379    fn passing(label: &str) -> CheckInfo {
10380        CheckInfo {
10381            label: label.to_owned(),
10382            verdict: Verdict::Pass,
10383            required: Some(false),
10384            run: None,
10385        }
10386    }
10387
10388    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10389        (label.to_owned(), run.map(str::to_owned))
10390    }
10391
10392    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10393        LandReruns {
10394            head: head.to_owned(),
10395            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10396        }
10397    }
10398
10399    #[test]
10400    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10401        let f = [failing("test (macos)", Some("7"))];
10402        assert_eq!(
10403            rerun_step(&f, None, "h", 100),
10404            RerunStep::Rerun(vec!["7".to_owned()])
10405        );
10406        let rec = reran("h", &[("7", 100)]);
10407        let grace = crate::release_watch::RERUN_GRACE;
10408        assert_eq!(
10409            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10410            RerunStep::Grace
10411        );
10412        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10413    }
10414
10415    #[test]
10416    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10417        let f = [failing("a", Some("7")), failing("ci/external", None)];
10418        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10419        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10420    }
10421
10422    #[test]
10423    fn a_moved_head_forgets_the_reruns() {
10424        let f = [failing("a", Some("7"))];
10425        let rec = reran("old", &[("7", 100)]);
10426        assert_eq!(
10427            rerun_step(&f, Some(&rec), "new", 101),
10428            RerunStep::Rerun(vec!["7".to_owned()])
10429        );
10430    }
10431
10432    #[test]
10433    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10434        let f = [
10435            failing("a", Some("7")),
10436            failing("b", Some("7")),
10437            failing("c", Some("8")),
10438        ];
10439        assert_eq!(
10440            rerun_step(&f, None, "h", 0),
10441            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10442        );
10443        let rec = reran("h", &[("7", 0)]);
10444        assert_eq!(
10445            rerun_step(&f, Some(&rec), "h", 1),
10446            RerunStep::Rerun(vec!["8".to_owned()])
10447        );
10448    }
10449
10450    #[test]
10451    fn only_blocking_failures_are_candidates_for_a_rerun() {
10452        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10453            label: label.to_owned(),
10454            verdict,
10455            required,
10456            run: Some(run.to_owned()),
10457        };
10458        let got = rerunnable(&[
10459            check("passed", Verdict::Pass, Some(true), "1"),
10460            check("optional", Verdict::Fail, Some(false), "2"),
10461            check("required", Verdict::Fail, Some(true), "3"),
10462            check("unknown", Verdict::Fail, None, "4"),
10463        ]);
10464        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10465        assert_eq!(labels, ["required", "unknown"]);
10466    }
10467
10468    fn unrelated_ci_fixture() -> CiEvidence {
10469        let failure = CiFailure {
10470            path: "tests/web_render.rs".into(),
10471            start_line: 42,
10472            end_line: 42,
10473            title: "question_identity_disclosure_copy_and_live_updates".into(),
10474            message: "assertion failed".into(),
10475        };
10476        CiEvidence {
10477            head: "a".into(),
10478            base: "base-sha".into(),
10479            changed: vec![
10480                "AGENTS.md".into(),
10481                "src/talk.rs".into(),
10482                "tests/consult_investigation.rs".into(),
10483            ],
10484            checks: vec![CiCheckEvidence {
10485                label: "coverage".into(),
10486                head_check: 70,
10487                base_check: 60,
10488                app: 1,
10489                run: "7".into(),
10490                base_run: "6".into(),
10491                head_failures: vec![failure.clone()],
10492                base_failures: vec![failure],
10493                rerun_exhausted: true,
10494            }],
10495        }
10496    }
10497
10498    #[test]
10499    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10500        let good = unrelated_ci_fixture();
10501        let red = vec!["coverage".into()];
10502        assert!(good.unrelated("a", &red));
10503        assert!(!good.unrelated("another-head", &red));
10504        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10505        assert!(!good.unrelated("a", &[]));
10506        for mutation in 0..8 {
10507            let mut bad = good.clone();
10508            match mutation {
10509                0 => bad.checks[0].rerun_exhausted = false,
10510                1 => bad.checks[0].base_failures.clear(),
10511                2 => bad.checks[0].head_failures.clear(),
10512                3 => bad.changed.push("tests/web_render.rs".into()),
10513                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10514                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10515                6 => bad.checks.push(bad.checks[0].clone()),
10516                _ => bad.base.clear(),
10517            }
10518            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10519        }
10520    }
10521
10522    #[test]
10523    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10524        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10525        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10526        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10527        assert!(matches!(
10528            decide(&pr, 2, 2, CHECKS_GRACE),
10529            Step::GiveUp { .. }
10530        ));
10531        let optional = CheckInfo {
10532            label: "coverage".into(),
10533            verdict: Verdict::Fail,
10534            required: Some(false),
10535            run: Some("7".into()),
10536        };
10537        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10538        assert_eq!(
10539            rerunnable_with(&[optional], true),
10540            vec![("coverage".into(), Some("7".into()))]
10541        );
10542    }
10543
10544    #[tokio::test]
10545    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10546        let mut state = landing_state();
10547        let mut red = coverage_unknown();
10548        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10549        red.pr.failing = vec!["coverage".into()];
10550        let forge = Scripted::new(vec![red], vec![]);
10551        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10552        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10553        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10554        assert!(forge.calls().contains(&"fix"));
10555        assert!(state.land_unrelated.is_some());
10556        assert!(state.parked);
10557        assert!(
10558            !state
10559                .events
10560                .iter()
10561                .any(|e| e.message.contains("fixer produced no commit"))
10562        );
10563    }
10564
10565    fn coverage_unknown() -> Seen {
10566        let mut s = red("a", Some("7"));
10567        s.pr.blocking = Blocking::Unsaid;
10568        s.merge_state = "UNKNOWN".into();
10569        s.pr.failing = vec!["coverage".into()];
10570        s.contexts[0].label = "coverage".into();
10571        s.contexts[0].required = Some(false);
10572        s
10573    }
10574
10575    #[tokio::test]
10576    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10577        let mut state = landing_state();
10578        let unknown = coverage_unknown();
10579        let mut unstable = unknown.clone();
10580        unstable.pr.blocking = Blocking::of("UNSTABLE");
10581        unstable.merge_state = "UNSTABLE".into();
10582        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10583        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10584        assert!(!forge.calls().contains(&"fix"));
10585        assert!(!forge.calls().contains(&"rerun"));
10586        assert_eq!(state.status, RunStatus::Merged);
10587        assert_eq!(state.pr.unwrap().round, 0);
10588    }
10589
10590    #[tokio::test]
10591    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10592        let mut state = landing_state();
10593        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10594        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10595        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10596        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10597        assert!(!forge.calls().contains(&"fix"));
10598        assert!(state.parked);
10599        assert_eq!(state.status, RunStatus::Landing);
10600        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10601        let hold = state.land_unrelated.as_ref().unwrap();
10602        let q = ask::Questions::open().get(&hold.question).unwrap();
10603        assert!(
10604            q.actions
10605                .values()
10606                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10607        );
10608        assert!(
10609            state
10610                .events
10611                .iter()
10612                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10613        );
10614        let first = state.land_unsaid.clone();
10615        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10616        state.parked = false;
10617        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10618        assert_eq!(state.land_unsaid, first);
10619        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10620    }
10621
10622    fn red(head: &str, run: Option<&str>) -> Seen {
10623        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10624        s.pr.failing = vec!["test (macos)".to_owned()];
10625        s.contexts = vec![CheckInfo {
10626            label: "test (macos)".to_owned(),
10627            verdict: Verdict::Fail,
10628            required: Some(true),
10629            run: run.map(str::to_owned),
10630        }];
10631        s
10632    }
10633
10634    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10635
10636    #[tokio::test]
10637    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10638        let mut state = landing_state();
10639        let forge = Scripted::new(
10640            vec![
10641                red("a", Some("7")),
10642                seen("a", Checks::Pending, "BLOCKED", false),
10643                seen("a", Checks::Green, "CLEAN", false),
10644            ],
10645            vec![(true, "")],
10646        );
10647        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10648        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10649        assert!(!forge.calls().contains(&"fix"));
10650        assert_eq!(state.status, RunStatus::Merged);
10651        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10652        let argvs = forge.argvs();
10653        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10654        assert!(
10655            state
10656                .events
10657                .iter()
10658                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10659        );
10660    }
10661
10662    #[tokio::test]
10663    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10664        let mut state = landing_state();
10665        let forge = Scripted::new(
10666            vec![
10667                red("a", Some("7")),
10668                seen("a", Checks::Green, "CLEAN", false),
10669            ],
10670            vec![(true, "")],
10671        );
10672        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10673        // The red view repeats for the grace, then the fixer is asked once.
10674        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10675        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10676        let calls = forge.calls();
10677        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10678        let fix_at = calls.iter().position(|c| *c == "fix");
10679        assert!(fix_at.is_none_or(|f| f > rerun_at));
10680    }
10681
10682    #[tokio::test]
10683    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10684        let mut state = landing_state();
10685        let mut v = red("a", Some("7"));
10686        v.pr.checks = Checks::Pending;
10687        v.contexts.push(CheckInfo {
10688            label: "slow".to_owned(),
10689            verdict: Verdict::Pending,
10690            required: Some(true),
10691            run: Some("8".to_owned()),
10692        });
10693        let forge = Scripted::new(
10694            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10695            vec![(true, "")],
10696        );
10697        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10698        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10699        assert_eq!(state.status, RunStatus::Merged);
10700    }
10701
10702    #[test]
10703    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10704        let mut v = red("a", Some("7"));
10705        v.contexts.push(CheckInfo {
10706            label: "same run, still going".to_owned(),
10707            verdict: Verdict::Pending,
10708            required: Some(true),
10709            run: Some("7".to_owned()),
10710        });
10711        assert!(rerunnable(&v.contexts).is_empty());
10712    }
10713
10714    #[tokio::test]
10715    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10716        let mut state = landing_state();
10717        state.config.graph.land_rounds = 0;
10718        let forge = Scripted::new(
10719            vec![
10720                red("a", Some("7")),
10721                seen("a", Checks::Green, "CLEAN", false),
10722            ],
10723            vec![(true, "")],
10724        );
10725        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10726        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10727        assert_eq!(state.status, RunStatus::Merged);
10728        assert!(!forge.calls().contains(&"fix"));
10729        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10730    }
10731
10732    #[tokio::test]
10733    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
10734        let mut state = landing_state();
10735        state.config.graph.land_rounds = 1;
10736        let forge = Scripted::new(
10737            vec![
10738                seen("a", Checks::Green, "CLEAN", true),
10739                red("b", Some("7")),
10740                seen("b", Checks::Green, "CLEAN", false),
10741            ],
10742            vec![(true, "")],
10743        );
10744        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10745            head: "b".to_owned(),
10746        });
10747        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10748        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10749        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
10750        assert_eq!(state.pr.as_ref().unwrap().round, 1);
10751        assert_eq!(state.status, RunStatus::Merged);
10752        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
10753        assert_eq!(
10754            value_of(&forge.argvs()[0], "--match-head-commit"),
10755            Some("b")
10756        );
10757    }
10758
10759    #[tokio::test]
10760    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
10761        let mut state = landing_state();
10762        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10763        *forge.rerun_fails.lock().unwrap() = true;
10764        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10765        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10766        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
10767        assert_eq!(
10768            forge.calls().iter().filter(|c| **c == "fix").count(),
10769            1,
10770            "{:?}",
10771            forge.calls()
10772        );
10773    }
10774
10775    #[tokio::test]
10776    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
10777        let mut state = landing_state();
10778        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10779        // The record was written just before the previous pass died.
10780        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
10781        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10782        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10783        assert!(forge.reruns.lock().unwrap().is_empty());
10784    }
10785
10786    #[tokio::test]
10787    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
10788        let mut state = landing_state();
10789        state.config.graph.rerun_flaky_checks = false;
10790        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
10791        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10792        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10793        assert!(forge.reruns.lock().unwrap().is_empty());
10794        assert!(forge.calls().contains(&"fix"));
10795
10796        let mut state = landing_state();
10797        let forge = Scripted::new(vec![red("a", None)], vec![]);
10798        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10799        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10800        assert!(forge.reruns.lock().unwrap().is_empty());
10801        assert!(forge.calls().contains(&"fix"));
10802    }
10803
10804    fn names(xs: &[&str]) -> BTreeSet<String> {
10805        xs.iter().map(|x| (*x).to_owned()).collect()
10806    }
10807
10808    #[test]
10809    fn a_required_check_the_rollup_never_listed_is_named() {
10810        let req = names(&["build"]);
10811        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
10812        assert!(why.contains("never reported: build"), "{why}");
10813        assert!(!why.contains("probably waiting for a review"), "{why}");
10814    }
10815
10816    #[test]
10817    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
10818        let why = waiting_on("BLOCKED", &[passing("review")], None);
10819        assert!(why.contains("could not be read"), "{why}");
10820        assert!(!why.contains("probably waiting for a review"), "{why}");
10821    }
10822
10823    #[test]
10824    fn all_required_reported_keeps_the_review_guess() {
10825        let req = names(&["build"]);
10826        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10827        assert!(why.contains("probably waiting for a review"), "{why}");
10828        assert!(!why.contains("never reported"), "{why}");
10829    }
10830
10831    #[test]
10832    fn required_names_match_the_rollup_ignoring_case_only() {
10833        let req = names(&["Build"]);
10834        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
10835        assert!(!why.contains("never reported"), "{why}");
10836    }
10837
10838    #[test]
10839    fn required_contexts_are_read_from_protection_and_rulesets() {
10840        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
10841        assert_eq!(
10842            parse_classic_required(classic),
10843            Some(names(&["build", "lint"]))
10844        );
10845        let rules = r#"[{"type":"pull_request","parameters":{}},
10846            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
10847        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
10848        assert_eq!(parse_ruleset_required("nope"), None);
10849        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
10850    }
10851
10852    #[test]
10853    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
10854        let shown = BTreeSet::new();
10855        let ok = seen("a", Checks::Green, "CLEAN", false);
10856        let guard = |s: Option<&Seen>| {
10857            direct_merge_is_safe(
10858                s,
10859                "A",
10860                &shown,
10861                0,
10862                4,
10863                Duration::ZERO,
10864                CiExpectation::Expected,
10865            )
10866        };
10867        assert!(guard(Some(&ok)));
10868        assert!(!guard(None));
10869        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
10870        let mut stale = ok.clone();
10871        stale.rollup_head = "old".to_owned();
10872        assert!(!guard(Some(&stale)));
10873        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
10874        assert!(!guard(Some(&merged_view("a"))));
10875    }
10876
10877    #[test]
10878    fn the_rollup_node_carries_whether_each_check_is_required() {
10879        let node = node_json("new", "SUCCESS", false)
10880            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
10881        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
10882        assert_eq!(s.contexts.len(), 1);
10883        assert_eq!(s.contexts[0].required, Some(true));
10884        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
10885        assert_eq!(s.contexts[0].required, None);
10886    }
10887
10888    #[tokio::test]
10889    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
10890        let mut state = landing_state();
10891        state.land_armed_head = Some("a".to_owned());
10892        let forge = Scripted::new(
10893            vec![seen("a", Checks::Green, "CLEAN", true)],
10894            vec![(false, "disable exploded")],
10895        );
10896        land_with(&mut state, URL, &forge).await.unwrap();
10897        assert!(!forge.calls().contains(&"fix"));
10898        assert_eq!(state.status, RunStatus::Blocked);
10899        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
10900        let why = state.merge.as_ref().unwrap().detail.clone();
10901        assert!(why.contains("disable exploded"), "{why}");
10902    }
10903}