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() || !state.repair_grants.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 _chat_merge = crate::chat_merge::target_lock_wait(&crate::run::home(), &pr.url).await?;
1744    let store = ask::Questions::open();
1745    // An answer belongs to the commit its panel showed. A question recorded
1746    // for another head - or none recorded at all, as for a question filed
1747    // before heads were tracked - is never reused: a fix or rebase push made
1748    // a commit the owner has not seen, and their word does not carry over.
1749    let reusable = state.land_approval.as_ref().and_then(|a| {
1750        store
1751            .list()
1752            .into_iter()
1753            .find(|q| q.id == a.question)
1754            .filter(|q| {
1755                q.status != ask::QuestionStatus::Abandoned
1756                    && state
1757                        .repair_grants
1758                        .last()
1759                        .is_none_or(|g| q.asked_at > g.receipt.at)
1760                    && (a.head.eq_ignore_ascii_case(head)
1761                        || q.resolution()
1762                            .as_deref()
1763                            .is_some_and(crate::merge_decision::stops))
1764            })
1765    });
1766    let reusable = match reusable {
1767        Some(q) if merge_consent_superseded(state, &q)? => None,
1768        other => other,
1769    };
1770    if reusable.is_none() {
1771        for stale in store
1772            .list()
1773            .into_iter()
1774            .filter(|q| q.run == state.id && q.node == APPROVAL_NODE && q.status.open())
1775        {
1776            let why =
1777                "the pull request head or operator decision changed; asked again for approval";
1778            if let Err(e) = store.update(&stale.id, |q| {
1779                q.abandon(why);
1780                Ok(())
1781            }) {
1782                tracing::warn!("could not retire the superseded approval question: {e:#}");
1783            }
1784        }
1785    }
1786
1787    let q = match reusable {
1788        Some(mut q) => {
1789            if upgrade_repair_choice(&mut q, state) {
1790                q = store
1791                    .update(&q.id, |current| {
1792                        upgrade_repair_choice(current, state);
1793                        Ok(())
1794                    })?
1795                    .0;
1796            }
1797            q
1798        }
1799        None => {
1800            let worktree = match state.winner() {
1801                Some(w) => w.worktree.clone(),
1802                None => state.repo.clone(),
1803            };
1804            // The diff is built from the commit being approved, not from the
1805            // branch name, which may already point somewhere else.
1806            let head = if head.is_empty() {
1807                state
1808                    .winner()
1809                    .map_or_else(|| "HEAD".to_owned(), |w| w.branch.clone())
1810            } else {
1811                head.to_owned()
1812            };
1813            // The diff is against what the remote's base holds, never the
1814            // local branch of that name; unreadable means less evidence.
1815            let remote = &state.config.merge.remote;
1816            let tracking = format!("{remote}/{}", state.base_branch);
1817            let base = if git::rev_exists(&worktree, &tracking).await {
1818                tracking
1819            } else {
1820                String::new()
1821            };
1822            let range = format!("{base}...{head}");
1823            // A failed `git` must not decide the merge: the panel degrades to
1824            // less evidence and the owner still chooses. Merging because the
1825            // diff could not be read would be the worst of both.
1826            let numstat = if base.is_empty() {
1827                String::new()
1828            } else {
1829                git::git_raw(&worktree, &["diff", "--numstat", "-M", &range])
1830                    .await
1831                    .map(|o| o.stdout)
1832                    .unwrap_or_default()
1833            };
1834            let diff = if base.is_empty() {
1835                String::new()
1836            } else {
1837                git::diff(&worktree, &base, &head).await.unwrap_or_default()
1838            };
1839            let commits: Vec<String> = if base.is_empty() {
1840                Vec::new()
1841            } else {
1842                git::git_raw(
1843                    &worktree,
1844                    &[
1845                        "log",
1846                        "--reverse",
1847                        "--format=%s",
1848                        &format!("{base}..{head}"),
1849                    ],
1850                )
1851                .await
1852                .map(|o| o.stdout)
1853                .unwrap_or_default()
1854                .lines()
1855                .filter(|l| !l.trim().is_empty())
1856                .map(str::to_owned)
1857                .collect()
1858            };
1859
1860            let w = words(&state.config.graph.language);
1861            let html = approval_panel(state, pr, &numstat, &diff, &commits, subject);
1862            let mut fresh = ask::Question::new(
1863                state.id.clone(),
1864                APPROVAL_NODE.to_owned(),
1865                "land".to_owned(),
1866                w.approval_summary(pr.number, subject),
1867                format!(
1868                    "{}\n\n{}",
1869                    approval_evidence(state),
1870                    w.approval_detail(&pr.url, &state.base_branch, subject, contested)
1871                ),
1872                approval_choices(state),
1873            );
1874            fresh.merge_target = Some(crate::merge_decision::Target {
1875                pr: pr.url.clone(),
1876                head: head.clone(),
1877                language: state.config.graph.language.clone(),
1878            });
1879            if fresh.choices.iter().any(|c| c == REPAIR) {
1880                fresh.actions.insert(
1881                    REPAIR.to_owned(),
1882                    ask::ChoiceAction::Resume {
1883                        run: state.id.clone(),
1884                    },
1885                );
1886                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.");
1887            }
1888            for choice in fresh.choices.clone() {
1889                if crate::merge_decision::stops(&choice)
1890                    && choice != HOLD
1891                    && choice != crate::merge_decision::DISMISS
1892                {
1893                    fresh.actions.insert(
1894                        choice,
1895                        ask::ChoiceAction::Resume {
1896                            run: state.id.clone(),
1897                        },
1898                    );
1899                }
1900            }
1901            store
1902                .put_panel(&mut fresh, &html, &[])
1903                .context("write the merge approval panel")?;
1904            store
1905                .put_executable(&mut fresh)
1906                .context("file the merge approval question")?;
1907            state.land_approval = Some(LandApproval {
1908                question: fresh.id.clone(),
1909                head: head.clone(),
1910            });
1911            state.event(
1912                "land",
1913                format!("asking for merge approval ({})", fresh.short()),
1914            );
1915            state.save()?;
1916            if let Err(e) = ask::notify(&state.config.notify, &fresh).await {
1917                // A broken webhook is not a reason to lose the merge: the
1918                // question is already on disk and the web UI already shows
1919                // it, so the operator still has a way in.
1920                tracing::warn!(
1921                    "could not notify about merge approval question {}: {e:#} - \
1922                     the web UI is the only surface for it now",
1923                    fresh.short()
1924                );
1925            }
1926            fresh
1927        }
1928    };
1929
1930    Ok(match q.status {
1931        ask::QuestionStatus::Open => ApprovalGate::Pending,
1932        // Nobody answered before `state.config.graph.answer_timeout` passed,
1933        // or the question was closed with no decision recorded underneath
1934        // this run - either way there is nothing left to wait on.
1935        ask::QuestionStatus::Abandoned => ApprovalGate::Held,
1936        // The merge gate does not speak `--thread`: an owner who talked back
1937        // instead of choosing never reaches `Answered`, so this arm only
1938        // ever sees an actual decision.
1939        ask::QuestionStatus::Answered => match approval(q.resolution().as_deref()) {
1940            Approval::Merge => ApprovalGate::Approved,
1941            Approval::Repair if !state.reviews.is_empty() => {
1942                if !state.merge_repairs.iter().any(|r| r.question == q.id) {
1943                    state.merge_repairs.push(crate::run::MergeRepair {
1944                        question: q.id.clone(),
1945                        head: head.to_owned(),
1946                        pr: pr.url.clone(),
1947                        reviews: state.reviews.clone(),
1948                        gate: state.gate.clone(),
1949                        gate_head: state.gate_head.clone(),
1950                        gate_fixes: state.gate_fixes.clone(),
1951                        pre_gate: state.pre_gate.clone(),
1952                        phase: crate::run::MergeRepairPhase::Requested,
1953                        failure: None,
1954                    });
1955                    state.land_approval = None;
1956                    state.event(
1957                        "repair",
1958                        "owner requested fix findings and re-review; merge is not approved",
1959                    );
1960                    state.save()?;
1961                }
1962                ApprovalGate::Repair
1963            }
1964            Approval::Repair | Approval::Hold => ApprovalGate::Held,
1965        },
1966    })
1967}
1968
1969/// Fold a rollup's entries into one verdict plus the failing checks' labels.
1970/// No I/O. An empty rollup is `Unknown`, never green.
1971fn rollup_verdict(rollup: &[GhCheck]) -> (Checks, Vec<String>) {
1972    let mut failing = Vec::new();
1973    let mut pending = false;
1974    let mut unknown = false;
1975    for check in rollup {
1976        match check.verdict() {
1977            Verdict::Pass => {}
1978            Verdict::Pending => pending = true,
1979            Verdict::Fail => failing.push(check.label()),
1980            Verdict::Unknown => unknown = true,
1981        }
1982    }
1983    let checks = if rollup.is_empty() {
1984        Checks::Unknown
1985    } else if pending {
1986        Checks::Pending
1987    } else if !failing.is_empty() {
1988        Checks::Red
1989    } else if unknown {
1990        Checks::Unknown
1991    } else {
1992        Checks::Green
1993    };
1994    (checks, failing)
1995}
1996
1997/// Parse `gh pr view --json url,number,state,statusCheckRollup,reviews,comments`
1998/// output into a [`PrState`]. No I/O.
1999pub fn parse_pr(json: &str) -> Result<PrState> {
2000    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2001    let state = match raw.state.to_ascii_uppercase().as_str() {
2002        "OPEN" => PrLifecycle::Open,
2003        "MERGED" => PrLifecycle::Merged,
2004        "CLOSED" => PrLifecycle::Closed,
2005        other => bail!("unknown pull request state `{other}`"),
2006    };
2007
2008    let (checks, failing) = rollup_verdict(&raw.status_check_rollup);
2009
2010    let mut review_comments = Vec::new();
2011    for r in raw.reviews {
2012        push_if_outstanding(
2013            &mut review_comments,
2014            ReviewComment {
2015                author: r.author.login,
2016                path: None,
2017                line: None,
2018                body: r.body,
2019            },
2020        );
2021    }
2022    for c in raw.comments {
2023        push_if_outstanding(
2024            &mut review_comments,
2025            ReviewComment {
2026                author: c.author.login,
2027                path: None,
2028                line: None,
2029                body: c.body,
2030            },
2031        );
2032    }
2033
2034    Ok(PrState {
2035        url: raw.url,
2036        number: raw.number,
2037        state,
2038        checks,
2039        failing,
2040        review_comments,
2041        blocking: Blocking::of(&raw.merge_state_status),
2042    })
2043}
2044
2045/// One rollup entry as the release watcher reads it.
2046#[derive(Debug, Clone, PartialEq, Eq)]
2047pub(crate) struct CheckView {
2048    pub name: String,
2049    pub verdict: Verdict,
2050    /// Workflow run behind the check, when its url names one.
2051    pub run: Option<String>,
2052    pub url: Option<String>,
2053}
2054
2055/// A pull request's lifecycle, head commit and per-check verdicts, without
2056/// [`rollup_verdict`]'s aggregation (a pending check anywhere hides a failure
2057/// from it, which is exactly what the release watcher must not inherit).
2058#[derive(Debug, Clone, PartialEq, Eq)]
2059pub(crate) struct RollupView {
2060    pub url: String,
2061    pub number: u64,
2062    pub state: PrLifecycle,
2063    pub head: String,
2064    pub checks: Vec<CheckView>,
2065}
2066
2067/// Parse `gh pr view --json url,number,state,headRefOid,statusCheckRollup`
2068/// output. No I/O.
2069pub(crate) fn parse_rollup(json: &str) -> Result<RollupView> {
2070    let raw: GhPr = serde_json::from_str(json).context("parse `gh pr view --json ...` output")?;
2071    let state = match raw.state.to_ascii_uppercase().as_str() {
2072        "OPEN" => PrLifecycle::Open,
2073        "MERGED" => PrLifecycle::Merged,
2074        "CLOSED" => PrLifecycle::Closed,
2075        other => bail!("unknown pull request state `{other}`"),
2076    };
2077    let checks = raw
2078        .status_check_rollup
2079        .iter()
2080        .map(|c| CheckView {
2081            name: c.label(),
2082            verdict: c.verdict(),
2083            run: c.url().and_then(run_of),
2084            url: c.url().map(str::to_owned),
2085        })
2086        .collect();
2087    Ok(RollupView {
2088        url: raw.url,
2089        number: raw.number,
2090        state,
2091        head: raw.head_ref_oid,
2092        checks,
2093    })
2094}
2095
2096/// Read just a pull request's lifecycle state - open, merged, or closed -
2097/// with none of the checks/reviews/comments [`land`] itself needs to decide
2098/// what to do next.
2099///
2100/// For a caller that only ever wants one fact and must not risk anything
2101/// else: `magi fold --merged` uses this to confirm a URL the operator hands
2102/// it is actually a merged pull request *before* touching a run's state, so a
2103/// typo or a still-open PR fails loudly instead of quietly recording a merge
2104/// that never happened.
2105pub async fn lifecycle(repo: &Path, pr_url: &str) -> Result<PrLifecycle> {
2106    let view = gh(
2107        repo,
2108        &[
2109            "pr".to_owned(),
2110            "view".to_owned(),
2111            pr_url.to_owned(),
2112            "--json".to_owned(),
2113            "state".to_owned(),
2114        ],
2115    )
2116    .await?;
2117    if !view.0 {
2118        bail!("gh pr view {pr_url}: {}", view.1);
2119    }
2120    // `parse_pr` reads every other field of `GhPr` as its serde default
2121    // (empty string, empty vec, zero) when this narrower `--json` selection
2122    // does not carry them - harmless, since only `.state` is read back.
2123    Ok(parse_pr(&view.1)?.state)
2124}
2125
2126/// A pull request the operator merged outside of `land::land`'s own loop,
2127/// found by asking GitHub about the run's own winning branch rather than
2128/// requiring the operator to go and find the URL themselves.
2129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2130pub struct ExternalMerge {
2131    /// The pull request's URL, ready to hand to [`correct_manual_merge`].
2132    pub url: String,
2133    /// The pull request's number.
2134    pub number: u64,
2135}
2136
2137#[derive(Debug, Deserialize)]
2138#[serde(rename_all = "camelCase")]
2139struct GhMergedPr {
2140    url: String,
2141    number: u64,
2142    merged_at: String,
2143    base_ref_name: String,
2144}
2145
2146/// Pure half of [`find_external_merge`]: given the raw `gh pr list --head
2147/// <branch> --state merged --json url,number,mergedAt,baseRefName` output,
2148/// decide whether exactly one of the pull requests it lists could actually
2149/// be *this* run's.
2150///
2151/// A branch name alone does not prove it: [`RunState::branch_for`] derives it
2152/// from the run's own short id, so a collision with some other, unrelated
2153/// task's merged pull request from a same-named branch is rare but not
2154/// impossible once branches are deleted and ids run out. Filtering on
2155/// `base_ref_name` (the branch this run actually targets) and `merged_at`
2156/// (which cannot predate the run itself) rules that case out. More than one
2157/// survivor is exactly as uninformative as zero — something this run cannot
2158/// tell apart from another — so only a unique survivor is returned.
2159fn pick_merged_pr(
2160    json: &str,
2161    base_branch: &str,
2162    created_at: Timestamp,
2163) -> Result<Option<ExternalMerge>> {
2164    let raw: Vec<GhMergedPr> =
2165        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2166    let mut matches: Vec<ExternalMerge> = Vec::new();
2167    for pr in raw {
2168        if pr.base_ref_name != base_branch {
2169            continue;
2170        }
2171        let Ok(merged_at) = pr.merged_at.parse::<Timestamp>() else {
2172            continue;
2173        };
2174        if merged_at < created_at {
2175            continue;
2176        }
2177        matches.push(ExternalMerge {
2178            url: pr.url,
2179            number: pr.number,
2180        });
2181    }
2182    if matches.len() == 1 {
2183        Ok(matches.pop())
2184    } else {
2185        Ok(None)
2186    }
2187}
2188
2189/// What `gh pr list --head <branch> --base <base> --state open` found.
2190#[derive(Debug, Clone, PartialEq, Eq)]
2191pub enum OpenPr {
2192    /// Nothing open: the caller creates one.
2193    None,
2194    /// Exactly one: the caller adopts it instead of creating a second.
2195    One {
2196        /// The pull request's URL.
2197        url: String,
2198        /// Its current title.
2199        title: String,
2200    },
2201    /// More than one: magi does not pick between them.
2202    Many(Vec<String>),
2203}
2204
2205#[derive(Debug, Deserialize)]
2206#[serde(rename_all = "camelCase")]
2207struct GhOpenPr {
2208    // `url` and `baseRefName` are required: a record missing either must be a
2209    // parse error, not a pull request that silently fails the base filter and
2210    // reads as "none open" (which would go on to create a duplicate).
2211    url: String,
2212    #[serde(default)]
2213    title: String,
2214    base_ref_name: String,
2215    // A fork's branch of the same name is not the task head; `gh pr list --head`
2216    // matches by branch name alone, so the filter has to be ours.
2217    #[serde(default)]
2218    is_cross_repository: bool,
2219}
2220
2221/// Pure half of [`find_open_pr`]: classify the raw `--json
2222/// number,url,title,baseRefName` output. Entries whose base is not `base` are
2223/// dropped even though the query already filtered on it, so a stub or an old
2224/// `gh` that ignores `--base` cannot get a pull request into the wrong branch
2225/// adopted.
2226pub fn pick_open_pr(json: &str, base: &str) -> Result<OpenPr> {
2227    let raw: Vec<GhOpenPr> =
2228        serde_json::from_str(json).context("parse `gh pr list ... --json ...` output")?;
2229    let mut hits: Vec<GhOpenPr> = raw
2230        .into_iter()
2231        .filter(|p| p.base_ref_name == base && !p.is_cross_repository)
2232        .collect();
2233    Ok(match hits.len() {
2234        0 => OpenPr::None,
2235        1 => {
2236            let p = hits.remove(0);
2237            OpenPr::One {
2238                url: p.url,
2239                title: p.title,
2240            }
2241        }
2242        _ => OpenPr::Many(hits.into_iter().map(|p| p.url).collect()),
2243    })
2244}
2245
2246/// Open pull requests whose head is `branch` and whose base is `base`. A
2247/// failing `gh` is an error carrying its own output, never "none": guessing
2248/// there is how a duplicate gets created.
2249pub async fn find_open_pr(repo: &Path, branch: &str, base: &str) -> Result<OpenPr> {
2250    let (ok, out) = gh(
2251        repo,
2252        &[
2253            "pr".to_owned(),
2254            "list".to_owned(),
2255            "--head".to_owned(),
2256            branch.to_owned(),
2257            "--base".to_owned(),
2258            base.to_owned(),
2259            "--state".to_owned(),
2260            "open".to_owned(),
2261            "--json".to_owned(),
2262            "number,url,title,baseRefName,isCrossRepository".to_owned(),
2263        ],
2264    )
2265    .await?;
2266    if !ok {
2267        bail!("gh pr list failed: {out}");
2268    }
2269    pick_open_pr(&out, base)
2270}
2271
2272#[derive(Debug, Deserialize)]
2273#[serde(rename_all = "camelCase")]
2274struct GhPrHead {
2275    head_ref_name: String,
2276    base_ref_name: String,
2277    state: String,
2278    // Required, like `GhOpenPr`'s fields: a record that cannot say whether the
2279    // head lives in a fork must be a parse error, never "same repository".
2280    is_cross_repository: bool,
2281    // Required too: it is what ties the pull request to the commits that were
2282    // actually proven to be on the base.
2283    head_ref_oid: String,
2284}
2285
2286/// Why [`closable`] said no, and whether asking again later could say yes.
2287#[derive(Debug, Clone, PartialEq, Eq)]
2288pub struct Refusal {
2289    /// A later attempt may succeed (the head moved, the view was unreadable);
2290    /// `false` means this pull request is simply not the run's to close.
2291    pub retry: bool,
2292    /// What was wrong.
2293    pub why: String,
2294}
2295
2296impl Refusal {
2297    fn final_(why: String) -> Self {
2298        Self { retry: false, why }
2299    }
2300}
2301
2302/// Pure half of [`close_superseded_pr`]: read `gh pr view --json
2303/// headRefName,baseRefName,state,isCrossRepository` and say whether closing
2304/// is safe, `Err` carrying the reason when it is not.
2305///
2306/// Closing is outward-facing, so every property is checked on what the forge
2307/// says now, not on what magi recorded: the head must be exactly `branch` in
2308/// this repository (a fork's branch of the same name is somebody else's), the
2309/// base must be `base`, and the pull request must still be open.
2310pub fn closable(
2311    json: &str,
2312    branch: &str,
2313    base: &str,
2314    verified: &[String],
2315) -> std::result::Result<(), Refusal> {
2316    let pr: GhPrHead = serde_json::from_str(json).map_err(|e| Refusal {
2317        retry: true,
2318        why: format!("could not read the pull request ({e})"),
2319    })?;
2320    if pr.head_ref_name != branch {
2321        return Err(Refusal::final_(format!(
2322            "its head is `{}`, not this run's `{branch}`",
2323            pr.head_ref_name
2324        )));
2325    }
2326    if pr.is_cross_repository {
2327        return Err(Refusal::final_("its head lives in a fork".to_owned()));
2328    }
2329    if pr.base_ref_name != base {
2330        return Err(Refusal::final_(format!(
2331            "it targets `{}`, not `{base}`",
2332            pr.base_ref_name
2333        )));
2334    }
2335    if !pr.state.eq_ignore_ascii_case("open") {
2336        return Err(Refusal::final_(format!(
2337            "it is already {}",
2338            pr.state.to_ascii_lowercase()
2339        )));
2340    }
2341    // Last, so a pull request that is not this run's at all is reported as
2342    // such. A head that is this branch but not a commit checked against the
2343    // base (somebody pushed since) may well get checked next time: retry.
2344    if !verified.contains(&pr.head_ref_oid) {
2345        return Err(Refusal {
2346            retry: true,
2347            why: format!(
2348                "its head {} is not a commit this run checked against the base",
2349                crate::already::short_sha(&pr.head_ref_oid)
2350            ),
2351        });
2352    }
2353    Ok(())
2354}
2355
2356/// Does `remote` point at something a forge could host - a URL or an scp-style
2357/// `user@host:path` - rather than a filesystem path or nothing at all? Judged
2358/// from the URL alone, so it answers the same on every machine, whatever `gh`
2359/// happens to be installed or logged in to.
2360async fn remote_is_forge(repo: &Path, remote: &str) -> bool {
2361    let Ok(url) = git::git(repo, &["remote", "get-url", remote]).await else {
2362        return false;
2363    };
2364    is_forge_url(url.trim())
2365}
2366
2367fn is_forge_url(url: &str) -> bool {
2368    url.contains("://") && !url.starts_with("file://")
2369        || url
2370            .split_once(':')
2371            .is_some_and(|(host, _)| host.contains('@') && !host.contains(['/', '\\']))
2372}
2373
2374/// Does this `gh` failure mean there is no GitHub to ask, as opposed to a
2375/// request that failed?
2376fn forge_unavailable(message: &str) -> bool {
2377    message.contains("known GitHub host") || message.contains("spawn gh")
2378}
2379
2380/// The comment left on a pull request closed because its change is already on
2381/// the base.
2382pub fn superseded_comment(base: &str, evidence: &crate::already::Evidence) -> String {
2383    let how = match evidence.proof {
2384        crate::already::Proof::PatchId => format!(
2385            "carried by commit {} on `{base}` with the same patch",
2386            evidence.names()
2387        ),
2388        crate::already::Proof::Ancestry => {
2389            format!("already in the history of `{base}` as {}", evidence.names())
2390        }
2391        crate::already::Proof::Tree => format!(
2392            "already part of `{base}` (merging this branch changes nothing at {})",
2393            crate::already::short_sha(&evidence.tip)
2394        ),
2395    };
2396    format!(
2397        "Closing: everything this branch adds is {how}, so there is nothing left to \
2398         land. This pull request was closed automatically after that was verified; \
2399         reopen it if you disagree."
2400    )
2401}
2402
2403/// Close the open pull request for `branch`, if there is one and it is
2404/// provably this run's, with a comment naming what supersedes it. `Ok(Ok(url))` is
2405/// the URL closed; `Ok(Err(why))` is a final "nothing to close" (no pull request,
2406/// not this run's, no forge); `Err` is anything a later attempt could resolve (a
2407/// failed `gh` call, a head that moved since it was checked), which the caller
2408/// must not treat as settled.
2409///
2410/// The recorded `state.pr` is preferred, else the forge is asked for an open
2411/// pull request on `branch`; either way the candidate is re-read and passed
2412/// through [`closable`] before anything is written; `verified` lists the
2413/// commits proven to be on the base, and the pull request's head must be one.
2414pub async fn close_superseded_pr(
2415    state: &mut RunState,
2416    branch: &str,
2417    evidence: &crate::already::Evidence,
2418    verified: &[String],
2419) -> Result<std::result::Result<String, String>> {
2420    let repo = state.repo.clone();
2421    let base = state.base_branch.clone();
2422    let url = match state.pr.as_ref().filter(|p| p.state == "open") {
2423        Some(p) => p.url.clone(),
2424        // No recorded pull request. A forge that cannot be asked at all (no
2425        // GitHub remote, no `gh`) says nothing about this run, so that is
2426        // "none found"; any other lookup failure is an error, because "could
2427        // not look" is not "nothing there" and the caller must retry.
2428        None if !remote_is_forge(&repo, &state.config.merge.remote).await => {
2429            return Ok(Err(
2430                "the remote is not a forge, so there is no pull request".to_owned(),
2431            ));
2432        }
2433        None => match find_open_pr(&repo, branch, &base).await {
2434            Err(e) if forge_unavailable(&format!("{e:#}")) => {
2435                return Ok(Err(format!("no forge to ask: {e:#}")));
2436            }
2437            Err(e) => return Err(e),
2438            Ok(OpenPr::One { url, .. }) => url,
2439            Ok(OpenPr::None) => return Ok(Err("no open pull request".to_owned())),
2440            Ok(OpenPr::Many(urls)) => {
2441                return Ok(Err(format!(
2442                    "{} open pull requests name it; not choosing between them",
2443                    urls.len()
2444                )));
2445            }
2446        },
2447    };
2448    let (ok, view) = gh(
2449        &repo,
2450        &[
2451            "pr".to_owned(),
2452            "view".to_owned(),
2453            url.clone(),
2454            "--json".to_owned(),
2455            "headRefName,headRefOid,baseRefName,state,isCrossRepository".to_owned(),
2456        ],
2457    )
2458    .await?;
2459    if !ok {
2460        bail!("gh pr view {url} failed: {view}");
2461    }
2462    if let Err(refusal) = closable(&view, branch, &base, verified) {
2463        // A pull request whose head cannot be tied to what was proven is not
2464        // one to walk away from: the caller keeps the run resumable.
2465        if refusal.retry {
2466            bail!("left {url} open: {}", refusal.why);
2467        }
2468        return Ok(Err(format!("left {url} open: {}", refusal.why)));
2469    }
2470    let (ok, out) = gh(
2471        &repo,
2472        &[
2473            "pr".to_owned(),
2474            "close".to_owned(),
2475            url.clone(),
2476            "--comment".to_owned(),
2477            superseded_comment(&base, evidence),
2478        ],
2479    )
2480    .await?;
2481    if !ok {
2482        bail!("gh pr close {url} failed: {out}");
2483    }
2484    if let Some(p) = state.pr.as_mut().filter(|p| p.url == url) {
2485        p.state = "closed".to_owned();
2486    }
2487    Ok(Ok(url))
2488}
2489
2490/// `gh pr edit <url> --title <title>`, for an adopted pull request whose title
2491/// differs from the one this run computed. Only the title: the body may have
2492/// been edited by the owner and cannot be compared.
2493pub async fn set_pr_title(state: &mut RunState, repo: &Path, url: &str, title: &str) -> Result<()> {
2494    let (title, _) = crate::github_text::prepare(state, title, "");
2495    edit_pr_title(repo, url, &title).await
2496}
2497
2498/// [`set_pr_title`] for a title the owner explicitly chose to post unmodified
2499/// (`github_text::POST_ORIGINAL` or `github_text::USE_REWRITE`): the posting
2500/// gate is skipped on purpose.
2501pub async fn set_pr_title_verbatim(repo: &Path, url: &str, title: &str) -> Result<()> {
2502    edit_pr_title(repo, url, title).await
2503}
2504
2505/// Post both fields of the owner's chosen rewrite to an adopted pull request,
2506/// unmodified and without the posting gate.
2507pub async fn set_pr_text_verbatim(repo: &Path, url: &str, title: &str, body: &str) -> Result<()> {
2508    let (ok, _) = gh(
2509        repo,
2510        &[
2511            "pr".to_owned(),
2512            "edit".to_owned(),
2513            url.to_owned(),
2514            "--title".to_owned(),
2515            title.to_owned(),
2516            "--body".to_owned(),
2517            body.to_owned(),
2518        ],
2519    )
2520    .await
2521    .map_err(|_| anyhow::anyhow!("could not run gh pr edit for the chosen rewrite"))?;
2522    if !ok {
2523        // Command output may echo the chosen text; keep it out of run events.
2524        bail!("gh pr edit failed while posting the chosen rewrite");
2525    }
2526    Ok(())
2527}
2528
2529async fn edit_pr_title(repo: &Path, url: &str, title: &str) -> Result<()> {
2530    let (ok, out) = gh(
2531        repo,
2532        &[
2533            "pr".to_owned(),
2534            "edit".to_owned(),
2535            url.to_owned(),
2536            "--title".to_owned(),
2537            title.to_owned(),
2538        ],
2539    )
2540    .await?;
2541    if !ok {
2542        bail!("gh pr edit failed: {out}");
2543    }
2544    Ok(())
2545}
2546
2547/// Ask GitHub whether this run's winning candidate branch was actually merged
2548/// somewhere `land::land`'s own loop never saw — the gap `magi fold
2549/// --merged` exists to close, minus the operator having to find the URL by
2550/// hand.
2551///
2552/// `Ok(None)` covers every case where nothing can be said with confidence: no
2553/// winner decided yet (nothing to check a branch for), no merged pull request
2554/// found, or [`pick_merged_pr`] found more than one candidate and would not
2555/// guess between them. Never wired to a weaker, URL-less signal like
2556/// [`branch_is_ancestor`] — a caller wanting that has to ask for it
2557/// separately, precisely because it cannot drive an automatic correction on
2558/// its own (see that function's own doc).
2559pub async fn find_external_merge(state: &RunState) -> Result<Option<ExternalMerge>> {
2560    let Some(winner) = state.winner() else {
2561        return Ok(None);
2562    };
2563    let branch = state.pr_branch().unwrap_or(&winner.branch).to_owned();
2564    let out = gh(
2565        &state.repo,
2566        &[
2567            "pr".to_owned(),
2568            "list".to_owned(),
2569            "--head".to_owned(),
2570            branch.clone(),
2571            "--state".to_owned(),
2572            "merged".to_owned(),
2573            "--json".to_owned(),
2574            "url,number,mergedAt,baseRefName".to_owned(),
2575        ],
2576    )
2577    .await?;
2578    if !out.0 {
2579        bail!("gh pr list --head {branch}: {}", out.1);
2580    }
2581    pick_merged_pr(&out.1, &state.base_branch, state.created_at)
2582}
2583
2584/// Whether `branch` is, right now, an ancestor of `base_branch` in the local
2585/// git graph — the weaker, URL-less signal that a branch landed somewhere.
2586///
2587/// Deliberately never consulted by [`find_external_merge`]: a base branch
2588/// that has moved since the run started can make an old, abandoned branch
2589/// look like an ancestor of the *current* base for reasons that have nothing
2590/// to do with a merge (a later commit that happens to supersede it, an
2591/// unrelated squash), and there is no pull request URL here to confirm
2592/// against or to land through anyway. Its only honest use is a weaker
2593/// notice — "this looks merged, go check" — never an automatic rewrite of
2594/// `status`/`merge`.
2595pub async fn branch_is_ancestor(repo: &Path, branch: &str, base_branch: &str) -> Result<bool> {
2596    let out = tokio::process::Command::new("git")
2597        .args(["merge-base", "--is-ancestor", branch, base_branch])
2598        .current_dir(repo)
2599        .quiet()
2600        .stdin(std::process::Stdio::null())
2601        .output()
2602        .await
2603        .context("spawn git merge-base --is-ancestor")?;
2604    Ok(out.status.success())
2605}
2606
2607/// Parse `host/owner/repo` out of a forge URL, with no network access.
2608///
2609/// The host is part of the slug, not discarded: `owner/repo` alone would
2610/// treat `github.example.com/o/r` and `github.com/o/r` as the same
2611/// repository, which is exactly the mix-up the same-repo guard exists to
2612/// catch. Returns `None` for anything that doesn't have a `<host>/<path>`
2613/// shape at all.
2614fn forge_slug(url: &str) -> Option<(String, &str)> {
2615    let rest = url.rsplit("://").next()?;
2616    let (host, path) = rest.split_once('/')?;
2617    if host.is_empty() {
2618        return None;
2619    }
2620    Some((host.to_ascii_lowercase(), path))
2621}
2622
2623/// Parse `host/owner/repo` out of a GitHub pull request URL, with no network
2624/// access - the first half of the same-repo guard [`correct_manual_merge`]
2625/// applies before it writes anything.
2626///
2627/// Returns `None` for anything that does not look like
2628/// `https://<host>/<owner>/<repo>/pull/<n>`, which the caller treats as
2629/// fail-closed: a URL this cannot make sense of refuses rather than guesses.
2630pub(crate) fn slug_of_pr_url(url: &str) -> Option<String> {
2631    let (host, path) = forge_slug(url)?;
2632    let mut segments = path.split('/');
2633    let owner = segments.next()?;
2634    let repo = segments.next()?;
2635    let kind = segments.next()?;
2636    if owner.is_empty() || repo.is_empty() || kind != "pull" {
2637        return None;
2638    }
2639    Some(format!("{host}/{owner}/{repo}"))
2640}
2641
2642/// Parse `host/owner/repo` out of a plain repository URL (no `/pull/<n>`
2643/// suffix), the shape `gh repo view --json url` returns - the other half of
2644/// the same-repo guard, matched against [`slug_of_pr_url`]'s output.
2645fn slug_of_repo_url(url: &str) -> Option<String> {
2646    let (host, path) = forge_slug(url)?;
2647    let mut segments = path.split('/');
2648    let owner = segments.next()?;
2649    let repo = segments.next()?;
2650    if owner.is_empty() || repo.is_empty() {
2651        return None;
2652    }
2653    Some(format!("{host}/{owner}/{repo}"))
2654}
2655
2656/// Refuse to correct a run against a pull request from a different
2657/// repository than the one it is recorded against.
2658///
2659/// This is the guard the shun/8c75 incident argued for: an operator ran
2660/// `magi fold --merged <shun PR url>` meaning to correct an old `Blocked` run
2661/// in a different repository, omitted the run id, and the id defaulted to
2662/// this machine's most recently created run - an unrelated, still-in-progress
2663/// run in a completely different repository - which then had its `status`
2664/// rewritten to `merged` from a pull request it had nothing to do with.
2665/// `correct_manual_merge` now requires an explicit id (see `magi fold`'s own
2666/// CLI help), but a mistyped or stale id could still name a run in a
2667/// different repository than the one the URL belongs to, so this checks that
2668/// independently rather than trusting the id alone.
2669///
2670/// Comparison is case-insensitive - GitHub owner/repo names are - and a
2671/// mismatch names both slugs rather than just refusing, so an operator whose
2672/// local checkout's `origin` is a fork of the repository the pull request was
2673/// opened against (a legitimate setup this cannot tell apart from a genuine
2674/// mix-up) can judge for themselves rather than being blocked with no way to
2675/// see why.
2676pub(crate) fn ensure_same_repo(run_repo_slug: &str, pr_repo_slug: &str) -> Result<()> {
2677    if run_repo_slug.eq_ignore_ascii_case(pr_repo_slug) {
2678        return Ok(());
2679    }
2680    bail!(
2681        "refusing to correct this run: it is recorded against {run_repo_slug}, but the pull \
2682         request URL belongs to {pr_repo_slug} - pass the run id whose repository the URL \
2683         actually belongs to (or, if `origin` is a fork opened against a different upstream, \
2684         verify by hand before treating this as a false positive)"
2685    );
2686}
2687
2688/// Ask the forge which `host/owner/repo` a local checkout's `origin` remote
2689/// actually resolves to, for the same-repo guard in [`correct_manual_merge`].
2690///
2691/// Asking `gh` rather than parsing `git remote -v` locally is deliberate: it
2692/// normalizes case, SSH vs. HTTPS remotes, and a renamed or transferred
2693/// repository the same way GitHub itself would recognize it, so the
2694/// comparison in [`ensure_same_repo`] is against the same canonical slug on
2695/// both sides. Reads `url` rather than `nameWithOwner` so the host is part of
2696/// the answer too - `nameWithOwner` alone cannot tell a `github.com` repo from
2697/// a same-named one on a GitHub Enterprise host.
2698async fn repo_slug(repo: &Path) -> Result<String> {
2699    let out = gh(
2700        repo,
2701        &[
2702            "repo".to_owned(),
2703            "view".to_owned(),
2704            "--json".to_owned(),
2705            "url".to_owned(),
2706        ],
2707    )
2708    .await?;
2709    if !out.0 {
2710        bail!("gh repo view --json url: {}", out.1);
2711    }
2712    #[derive(Debug, Deserialize)]
2713    struct GhRepo {
2714        url: String,
2715    }
2716    let parsed: GhRepo = serde_json::from_str(&out.1)
2717        .with_context(|| format!("parse `gh repo view` output: {}", out.1))?;
2718    slug_of_repo_url(&parsed.url)
2719        .with_context(|| format!("could not parse a host/owner/repo out of {}", parsed.url))
2720}
2721
2722/// Confirm `url` is actually a merged pull request, then rewrite `state`'s
2723/// `status` and `merge` exactly as the automatic land loop (`land::land`)
2724/// would have written them had magi opened and merged this pull request
2725/// itself.
2726///
2727/// This is `magi fold --merged`'s whole implementation, and also what the
2728/// web `fold-merged` route calls once it has a URL in hand — an operator
2729/// recovery path for a merge magi could not finish on its own: a PR title too
2730/// long for the GraphQL mutation, `gh pr create` unreachable, a stale token -
2731/// closed by hand with a pull request magi never opened and so never
2732/// recorded. Reusing `land::land` rather than writing `status`/`merge`
2733/// directly keeps this one authoritative: a merged pull request decides
2734/// `Step::Done { merged: true }` on the very first read, before any of
2735/// `land`'s own checks/fix/rebase machinery can run, which is what makes it
2736/// safe to call here even though this pull request was never magi's own.
2737///
2738/// [`ensure_same_repo`] is checked before anything else: a pull request from
2739/// a different repository than the one `state` is recorded against is
2740/// refused outright, regardless of its lifecycle. This is the guard for a
2741/// URL an *operator* hands in - the CLI or the web route - where a stale or
2742/// mistyped run id could otherwise get corrected from an unrelated
2743/// repository's pull request (see the shun/8c75 incident in `magi fold`'s own
2744/// CLI help). The automatic janitor sweep (`clean::reconcile_external_merges`)
2745/// goes through [`correct_confirmed_external_merge`] instead, which skips
2746/// this check: its `url` was never operator-supplied, it comes from
2747/// [`find_external_merge`] querying `gh` from inside `state.repo` itself, so
2748/// it is already guaranteed to name a pull request in that same repository -
2749/// re-deriving and re-checking the repository here would only be a second
2750/// `gh repo view` call that can fail for reasons that have nothing to do with
2751/// correctness (a rate limit, a network blip), turning a self-heal that would
2752/// otherwise have succeeded into a run left `Blocked` for another pass.
2753///
2754/// [`lifecycle`] is checked next and separately so a mistyped or still-open
2755/// URL fails loudly without writing anything, rather than handing an open
2756/// pull request to the full autonomous loop by accident.
2757///
2758/// Correcting `status` this way does not run `bump::after_merge`
2759/// (`src/bump.rs`): that call is made only from `graph::Runner::run_land`,
2760/// which this path never goes through. A release version bump the change
2761/// might have earned is therefore not filed automatically and has to be
2762/// requested by hand - recorded as an event on the run so the gap is visible
2763/// to whoever reads it later, not just wherever this was called from. Follow-up
2764/// tasks for findings the merge left open (`crate::followup`) *are* filed here.
2765///
2766/// Returns the status before and after, so every caller (CLI, janitor, web
2767/// route) can build its own log line or response from the same pair rather
2768/// than each re-deriving it.
2769pub async fn correct_manual_merge(
2770    state: &mut RunState,
2771    url: &str,
2772) -> Result<(RunStatus, RunStatus)> {
2773    let Some(pr_slug) = slug_of_pr_url(url) else {
2774        bail!(
2775            "could not parse an owner/repo out of {url}; refusing to guess which repository \
2776             this pull request belongs to"
2777        );
2778    };
2779    let run_slug = repo_slug(&state.repo).await?;
2780    ensure_same_repo(&run_slug, &pr_slug)?;
2781    correct_merge(state, url).await
2782}
2783
2784/// The janitor's own entry point into the same correction
2785/// [`correct_manual_merge`] performs for an operator-supplied URL, minus the
2786/// same-repo guard - see that function's own doc for why skipping it here is
2787/// safe rather than a hole: [`clean::reconcile_external_merges`] only ever
2788/// calls this with a `url` [`find_external_merge`] already found by querying
2789/// `state.repo`'s own remote, so the guard could never do anything here but
2790/// fail on its own transient errors.
2791///
2792/// [`crate::clean`] is the only caller; `pub(crate)` rather than private only
2793/// because it lives in a different module.
2794pub(crate) async fn correct_confirmed_external_merge(
2795    state: &mut RunState,
2796    url: &str,
2797) -> Result<(RunStatus, RunStatus)> {
2798    correct_merge(state, url).await
2799}
2800
2801/// Same pull request, same repository: the url, or the number within one repo.
2802fn names_same_pr(a: &RunState, url: &str, number: u64, repo: &Path) -> bool {
2803    let Some(pr) = a.pr.as_ref() else {
2804        return false;
2805    };
2806    if !url.is_empty()
2807        && pr
2808            .url
2809            .trim_end_matches('/')
2810            .eq_ignore_ascii_case(url.trim_end_matches('/'))
2811    {
2812        return true;
2813    }
2814    number > 0
2815        && pr.number == number
2816        && match (a.repo.canonicalize(), repo.canonicalize()) {
2817            (Ok(x), Ok(y)) => x == y,
2818            _ => a.repo == repo,
2819        }
2820}
2821
2822/// Rewrite `pr.state` to a final state on every run record under `home` that
2823/// `decide` picks, and report how many were rewritten.
2824///
2825/// Lifecycle writes through to other runs are deliberately narrow (task-head
2826/// ownership is recorded separately): only a **terminal** run (never one a
2827/// driver may still be writing), only a proven-stopped driver with no daemon
2828/// claim (the shared liveness judge), only a record
2829/// whose `pr.state` is still `open`, and only `merged` / `closed` ever goes in.
2830/// The record is read as folding reads it (no schema check: every bump so far
2831/// only added fields, and the whole struct round-trips) and written through
2832/// [`RunState::save_under`], the path every record uses, so nothing but
2833/// `pr.state` and an event line changes (and `updated_at`, as for any save).
2834/// A run that cannot be read or written is skipped with a warning.
2835fn rewrite_open_prs(
2836    home: &Path,
2837    decide: &mut dyn FnMut(&RunState) -> Option<PrLifecycle>,
2838) -> usize {
2839    let now = Timestamp::now();
2840    let mut changed = 0;
2841    for id in crate::run::list_ids_in(&home.join("runs")) {
2842        let Ok(_owner) = crate::pr_head::owner_lock(home, &id) else {
2843            continue;
2844        };
2845        let path = home.join("runs").join(&id).join("run.json");
2846        let Ok(body) = std::fs::read_to_string(&path) else {
2847            continue;
2848        };
2849        let Ok(mut state) = serde_json::from_str::<RunState>(&body) else {
2850            continue;
2851        };
2852        if !state.status.done()
2853            || state.pr.as_ref().is_none_or(|p| p.state != "open")
2854            || state.liveness_under(home, crate::daemon::is_working_on(home, &id, now))
2855                != crate::run::Liveness::Dead
2856        {
2857            continue;
2858        }
2859        let Some(to @ (PrLifecycle::Merged | PrLifecycle::Closed)) = decide(&state) else {
2860            continue;
2861        };
2862        if let Some(pr) = state.pr.as_mut() {
2863            pr.state = to.as_str().to_owned();
2864        }
2865        let url = state.pr.as_ref().map(|p| p.url.clone()).unwrap_or_default();
2866        state.event(
2867            "land",
2868            format!("recorded {url} as {}: another run settled it", to.as_str()),
2869        );
2870        match state.save_under(home) {
2871            Ok(()) => changed += 1,
2872            Err(e) => tracing::warn!("write pr state through to run {id}: {e:#}"),
2873        }
2874    }
2875    changed
2876}
2877
2878/// A run reached a final state for its pull request: tell every other terminal
2879/// run in the same repository that names the same pull request (handed-over
2880/// predecessors, blocked attempts, anything), so their records stop saying
2881/// `open`. Best effort; `run`'s own record is the caller's.
2882pub(crate) fn write_pr_state_through(run: &RunState, to: PrLifecycle) {
2883    if to == PrLifecycle::Open {
2884        return;
2885    }
2886    let Some(home) = crate::run::try_home() else {
2887        return;
2888    };
2889    write_pr_state_through_in(&home, run, to);
2890}
2891
2892pub(crate) fn write_pr_state_through_in(home: &Path, run: &RunState, to: PrLifecycle) -> usize {
2893    let Some(pr) = run.pr.as_ref() else {
2894        return 0;
2895    };
2896    let (url, number) = (pr.url.clone(), pr.number);
2897    rewrite_open_prs(home, &mut |other| {
2898        (other.id != run.id && names_same_pr(other, &url, number, &run.repo)).then_some(to)
2899    })
2900}
2901
2902/// Terminal runs whose record still says their pull request is open, as
2903/// `(run id, repo, url)`, for [`repair_stale_pr_states`].
2904pub(crate) fn stale_open_prs(home: &Path) -> Vec<(String, PathBuf, String)> {
2905    let now = Timestamp::now();
2906    let mut out = Vec::new();
2907    for id in crate::run::list_ids_in(&home.join("runs")) {
2908        let path = home.join("runs").join(&id).join("run.json");
2909        let Ok(body) = std::fs::read_to_string(&path) else {
2910            continue;
2911        };
2912        let Ok(state) = serde_json::from_str::<RunState>(&body) else {
2913            continue;
2914        };
2915        if let Some(pr) = state.pr.as_ref()
2916            && state.status.done()
2917            && pr.state == "open"
2918            && !pr.url.is_empty()
2919            && !crate::daemon::is_working_on(home, &id, now)
2920        {
2921            out.push((id, state.repo.clone(), pr.url.clone()));
2922        }
2923    }
2924    out
2925}
2926
2927/// Apply forge answers (`pr url -> state`) to every stale terminal record.
2928/// A url with no answer (the forge was unreadable) changes nothing.
2929pub(crate) fn apply_pr_states(home: &Path, known: &BTreeMap<String, PrLifecycle>) -> usize {
2930    rewrite_open_prs(home, &mut |s| {
2931        s.pr.as_ref().and_then(|p| known.get(&p.url)).copied()
2932    })
2933}
2934
2935/// One-time (and idempotent) repair of records that froze an `open` pull
2936/// request: ask the forge about each distinct pull request that a terminal run
2937/// still calls open - at most `max_lookups` of them - and rewrite the merged
2938/// and closed ones. A genuinely open pull request is left alone, and a lookup
2939/// that fails is "unknown, change nothing". Returns `(records rewritten,
2940/// lookups that failed)`.
2941pub async fn repair_stale_pr_states(home: &Path, max_lookups: usize) -> (usize, usize) {
2942    let mut known = BTreeMap::new();
2943    let mut failed = 0;
2944    let mut seen = BTreeSet::new();
2945    for (_, repo, url) in stale_open_prs(home) {
2946        if known.len() + failed >= max_lookups || !seen.insert(url.clone()) {
2947            continue;
2948        }
2949        match lifecycle(&repo, &url).await {
2950            Ok(state) => {
2951                known.insert(url, state);
2952            }
2953            Err(e) => {
2954                tracing::warn!("repair pr state of {url}: {e:#}");
2955                failed += 1;
2956            }
2957        }
2958    }
2959    (apply_pr_states(home, &known), failed)
2960}
2961
2962async fn correct_merge(state: &mut RunState, url: &str) -> Result<(RunStatus, RunStatus)> {
2963    match lifecycle(&state.repo, url).await? {
2964        PrLifecycle::Merged => {}
2965        other => bail!(
2966            "{url} is {}, not merged; refusing to record {} as merged on a guess",
2967            other.as_str(),
2968            state.id
2969        ),
2970    }
2971    let before = state.status;
2972    if let Err(e) = land(state, url).await {
2973        // `land` sets `status` to `Landing` and saves before its first read
2974        // of the pull request — see its own doc — so a failure here (a
2975        // transient `gh` hiccup between the two forge reads this function
2976        // makes) can leave the run stuck on that in-between value with
2977        // nothing left driving it. Land it on the same terminal shape an
2978        // automated `land` failure lands on instead of leaving it stuck.
2979        state.status = RunStatus::Blocked;
2980        state.event("fold", format!("manual-merge correction failed: {e:#}"));
2981        state.save()?;
2982        return Err(e).context(format!("confirming the merge of {url}"));
2983    }
2984    state.event(
2985        "fold",
2986        "operator recorded this pull request as a manual merge; this run never \
2987         re-entered `land`, so `bump::after_merge` did not run for it - a release \
2988         bump this change might warrant has to be filed by hand",
2989    );
2990    // Unlike the bump, the findings the merge left open are filed on this
2991    // path too: nothing else would ever carry them forward.
2992    if state.status == RunStatus::Merged {
2993        crate::followup::after_merge(state, url).await;
2994    }
2995    state.save()?;
2996    Ok((before, state.status))
2997}
2998
2999/// Parse `gh api repos/{owner}/{repo}/pulls/<n>/comments` into inline review
3000/// comments. No I/O.
3001///
3002/// `gh pr view` does not surface inline comments, and inline is exactly where
3003/// both review bots put their findings - a landing loop that read only the
3004/// top-level thread would never see the thing it is supposed to fix.
3005pub fn parse_inline_comments(json: &str) -> Result<Vec<ReviewComment>> {
3006    let raw: Vec<GhInline> =
3007        serde_json::from_str(json).context("parse `gh api .../pulls/<n>/comments` output")?;
3008    let mut out = Vec::new();
3009    for c in raw {
3010        push_if_outstanding(
3011            &mut out,
3012            ReviewComment {
3013                author: c.user.login,
3014                path: c.path,
3015                line: c.line,
3016                body: c.body,
3017            },
3018        );
3019    }
3020    Ok(out)
3021}
3022
3023/// Keep a comment only when it asks for something.
3024///
3025/// An inline comment always does: it names a file and a line. A top-level
3026/// comment is dropped when it is empty, when it is magi's own, or when it is
3027/// [noise](is_noise).
3028fn push_if_outstanding(out: &mut Vec<ReviewComment>, comment: ReviewComment) {
3029    if comment.body.trim().is_empty() || comment.body.contains(MARKER) {
3030        return;
3031    }
3032    if comment.path.is_none() && is_noise(&comment.body) {
3033        return;
3034    }
3035    out.push(comment);
3036}
3037
3038/// Is this comment body machinery rather than a finding?
3039///
3040/// Two tests, both structural, because guessing from prose is how a "looks
3041/// good to me" turns into a fix round:
3042///
3043/// 1. The bot said so - the body carries one of the [`NOT_A_REVIEW`] markers
3044///    with which CodeRabbit labels its trigger notice, its walkthrough, and its
3045///    footer.
3046/// 2. It asks for nothing - once HTML comments, `<details>` blocks, headings,
3047///    horizontal rules, and the bot's own status banner are removed, every
3048///    remaining line is a task-list item. That is exactly the shape of the
3049///    comment the Claude review job posts while it is still working.
3050///
3051/// Anything else is input, including bot prose. A bot that writes a paragraph
3052/// has said something, and the fix prompt tells the fixer it may decline a
3053/// comment with an argument - a wasted sentence in a prompt is cheaper than a
3054/// missed finding.
3055pub fn is_noise(body: &str) -> bool {
3056    if NOT_A_REVIEW.iter().any(|m| body.contains(m)) {
3057        return true;
3058    }
3059    let mut content = false;
3060    for line in strip_blocks(body).lines() {
3061        let line = unquote(line);
3062        if line.is_empty() || is_checklist(line) || is_decoration(line) || is_banner(line) {
3063            continue;
3064        }
3065        content = true;
3066        break;
3067    }
3068    !content
3069}
3070
3071/// Remove HTML comments and collapsed `<details>` blocks.
3072fn strip_blocks(body: &str) -> String {
3073    let mut out = String::with_capacity(body.len());
3074    let mut rest = body;
3075    loop {
3076        let open = ["<!--", "<details>"]
3077            .iter()
3078            .filter_map(|tag| rest.find(tag).map(|i| (i, *tag)))
3079            .min_by_key(|(i, _)| *i);
3080        let Some((at, tag)) = open else {
3081            out.push_str(rest);
3082            return out;
3083        };
3084        out.push_str(&rest[..at]);
3085        let after = &rest[at + tag.len()..];
3086        let close = if tag == "<!--" { "-->" } else { "</details>" };
3087        match after.find(close) {
3088            Some(end) => rest = &after[end + close.len()..],
3089            // Unterminated: the rest of the body is inside the block.
3090            None => return out,
3091        }
3092    }
3093}
3094
3095/// Strip blockquote markers, which both bots wrap their callouts in.
3096fn unquote(line: &str) -> &str {
3097    let mut s = line.trim();
3098    while let Some(rest) = s.strip_prefix('>') {
3099        s = rest.trim_start();
3100    }
3101    s.trim()
3102}
3103
3104/// `- [ ]` / `- [x]`, in any of the bullet styles GitHub renders.
3105fn is_checklist(line: &str) -> bool {
3106    let rest = line
3107        .strip_prefix("- ")
3108        .or_else(|| line.strip_prefix("* "))
3109        .unwrap_or("");
3110    let rest = rest.trim_start();
3111    matches!(
3112        rest.get(..3),
3113        Some("[ ]") | Some("[x]") | Some("[X]") | Some("[*]")
3114    )
3115}
3116
3117/// A heading, a horizontal rule, or a callout tag - shape, never content.
3118fn is_decoration(line: &str) -> bool {
3119    line.starts_with('#')
3120        || line.starts_with("[!")
3121        || (line.len() >= 3 && line.chars().all(|c| matches!(c, '-' | '=' | '*' | '_')))
3122}
3123
3124/// A line that is nothing but emphasis and links.
3125///
3126/// Both review jobs open with a status banner
3127/// (`**Claude finished ... in 4m 14s** —— [View job](url)`). It reads as prose
3128/// to a line-based test and asks for nothing, so it is measured the same way a
3129/// heading is: strip the markup, and if no word survives, it was decoration.
3130fn is_banner(line: &str) -> bool {
3131    let plain = drop_spans(line, "**", "**");
3132    let plain = if plain.contains("](") {
3133        drop_spans(&plain, "[", ")")
3134    } else {
3135        plain
3136    };
3137    !plain.chars().any(char::is_alphanumeric)
3138}
3139
3140/// Remove every `open` .. `close` span, including the delimiters. An
3141/// unterminated span swallows the rest of the input, which is what a reader
3142/// sees too.
3143fn drop_spans(s: &str, open: &str, close: &str) -> String {
3144    let mut out = String::with_capacity(s.len());
3145    let mut rest = s;
3146    while let Some(at) = rest.find(open) {
3147        out.push_str(&rest[..at]);
3148        let after = &rest[at + open.len()..];
3149        match after.find(close) {
3150            Some(end) => rest = &after[end + close.len()..],
3151            None => return out,
3152        }
3153    }
3154    out.push_str(rest);
3155    out
3156}
3157
3158/// The lock that keeps at most one run per repository actually moving the
3159/// base branch at a time: a rebase push, or `gh pr merge`.
3160///
3161/// Deliberately narrow. Everything else in [`land`]'s loop - watching CI,
3162/// running a fix round in the winner's own worktree, waiting on the owner's
3163/// approval - touches nothing a *different* run in the same repository could
3164/// collide with, and holding a lock across any of that would serialise one
3165/// run's CI wait (up to [`WAIT_CEILING`]) against another run's land-approval
3166/// resume, which is precisely the "must not wait on another task" property
3167/// the daemon's slot-freeing exists to give a resume. Only the two moments
3168/// that actually write to the shared base branch need mutual exclusion, and
3169/// both are brief.
3170///
3171/// One entry per repository, each its own `tokio::sync::Mutex`, so two
3172/// different repositories' runs never wait on each other. The outer
3173/// `std::sync::Mutex` guards only the map itself, held long enough to find or
3174/// insert an entry and clone its `Arc`, never across an `.await`.
3175fn repo_merge_lock(repo: &Path) -> Arc<tokio::sync::Mutex<()>> {
3176    static LOCKS: std::sync::LazyLock<
3177        std::sync::Mutex<BTreeMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>,
3178    > = std::sync::LazyLock::new(|| std::sync::Mutex::new(BTreeMap::new()));
3179    LOCKS
3180        .lock()
3181        .unwrap_or_else(std::sync::PoisonError::into_inner)
3182        .entry(repo.to_path_buf())
3183        .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
3184        .clone()
3185}
3186
3187/// `owner/repo` out of a pull request url, falling back to the checkout's
3188/// directory name when the url is not the usual `host/owner/repo/pull/N`.
3189fn repo_label(repo: &Path, pr_url: &str) -> String {
3190    let parts: Vec<&str> = pr_url.split('/').collect();
3191    if let Some(at) = parts.iter().rposition(|p| *p == "pull")
3192        && at >= 2
3193        && !parts[at - 1].is_empty()
3194        && !parts[at - 2].is_empty()
3195    {
3196        return format!("{}/{}", parts[at - 2], parts[at - 1]);
3197    }
3198    repo.file_name()
3199        .map(|n| n.to_string_lossy().into_owned())
3200        .unwrap_or_default()
3201}
3202
3203/// The operator-facing sentence for a merge that went ahead with red checks,
3204/// or `None` when the checks were not red. Judged on `checks`, not on
3205/// `failing`, which can be non-empty on a green observation.
3206fn red_merge_summary(repo_name: &str, pr: &PrState) -> Option<String> {
3207    (pr.checks == Checks::Red).then(|| {
3208        let details = crate::notices::format_list(&pr.failing)
3209            .map(|names| format!(": {names}"))
3210            .unwrap_or_default();
3211        format!(
3212            "Merged {repo_name} PR #{} with red checks{details} ({})",
3213            pr.number, pr.url
3214        )
3215    })
3216}
3217
3218/// After a merge that succeeded: record which checks were red and tell the
3219/// operator. The decision to merge is already made; this only makes it audible.
3220/// Best-effort - a broken notifier never fails the run.
3221async fn announce_red_merge(state: &mut RunState, pr: &PrState) {
3222    let repo_name = repo_label(&state.repo, &pr.url);
3223    let Some(summary) = red_merge_summary(&repo_name, pr) else {
3224        return;
3225    };
3226    if let Some(rec) = state.pr.as_mut() {
3227        rec.red_at_merge = pr.failing.clone();
3228    }
3229    state.event("land", summary.clone());
3230    // The notice pages through `[notify]` itself, once, unless a question
3231    // already carries the cause.
3232    crate::notices::raise_with(
3233        crate::notices::merged_red(&state.id, &summary),
3234        &state.config.notify,
3235    );
3236}
3237
3238/// Run the loop against a real pull request until it merges or the budget runs
3239/// out.
3240///
3241/// The caller decides whether landing happens at all: this is only reached when
3242/// `graph.land` is on. Returns the last observation, so the caller can report
3243/// what magi was looking at when it stopped.
3244pub async fn land(state: &mut RunState, pr_url: &str) -> Result<PrState> {
3245    land_with(state, pr_url, &GhForge).await
3246}
3247
3248/// The forge calls whose timing the loop's decisions depend on, behind a seam
3249/// so a test can script what the pull request looks like from one observation
3250/// to the next. Comments, logs and rebases stay on `gh` and `git` directly.
3251trait Forge {
3252    async fn close(&self, repo: &Path, pr_url: &str, comment: &str) -> Result<()> {
3253        let (ok, out) = gh(
3254            repo,
3255            &[
3256                "pr".into(),
3257                "close".into(),
3258                pr_url.into(),
3259                "--comment".into(),
3260                comment.into(),
3261            ],
3262        )
3263        .await?;
3264        if !ok {
3265            bail!("PR closure failed: {out}");
3266        }
3267        Ok(())
3268    }
3269    async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
3270        None
3271    }
3272    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen>;
3273    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)>;
3274    async fn poll(&self);
3275    /// Rerun the failed jobs of one workflow run.
3276    async fn rerun(&self, repo: &Path, run: &str) -> Result<()>;
3277    /// Unix seconds, behind the seam so a test owns the clock.
3278    fn now(&self) -> i64;
3279    /// The check contexts the base branch requires, for a stop reason only.
3280    /// `None` when they could not be read, which is not the same as none.
3281    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>>;
3282    #[allow(clippy::too_many_arguments)]
3283    async fn fix(
3284        &self,
3285        state: &mut RunState,
3286        pr: &PrState,
3287        round: usize,
3288        budget: usize,
3289        reason: &str,
3290        logs: &str,
3291    ) -> Result<Fixed>;
3292}
3293
3294struct GhForge;
3295
3296impl Forge for GhForge {
3297    async fn attribution(&self, state: &RunState, seen: &Seen) -> Option<CiEvidence> {
3298        collect_ci_evidence(state, seen).await
3299    }
3300    async fn view(&self, repo: &Path, pr_url: &str) -> Result<Seen> {
3301        observe(repo, pr_url).await
3302    }
3303    async fn merge(&self, repo: &Path, argv: &[String]) -> Result<(bool, String)> {
3304        gh(repo, argv).await
3305    }
3306    async fn poll(&self) {
3307        tokio::time::sleep(POLL).await;
3308    }
3309    async fn rerun(&self, repo: &Path, run: &str) -> Result<()> {
3310        let args = [
3311            "run".to_owned(),
3312            "rerun".to_owned(),
3313            run.to_owned(),
3314            "--failed".to_owned(),
3315        ];
3316        let (ok, out) = gh(repo, &args).await?;
3317        if !ok {
3318            bail!("gh run rerun {run}: {out}");
3319        }
3320        Ok(())
3321    }
3322    fn now(&self) -> i64 {
3323        jiff::Timestamp::now().as_second()
3324    }
3325    async fn required_contexts(&self, repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3326        required_contexts_of(repo, base).await
3327    }
3328    async fn fix(
3329        &self,
3330        state: &mut RunState,
3331        pr: &PrState,
3332        round: usize,
3333        budget: usize,
3334        reason: &str,
3335        logs: &str,
3336    ) -> Result<Fixed> {
3337        fix_round(state, pr, round, budget, reason, logs).await
3338    }
3339}
3340
3341/// Percent-encode a branch name for a URL path segment (`/` included).
3342fn encode_path_segment(s: &str) -> String {
3343    let mut out = String::new();
3344    for b in s.bytes() {
3345        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
3346            out.push(b as char);
3347        } else {
3348            let _ = write!(out, "%{b:02X}");
3349        }
3350    }
3351    out
3352}
3353
3354/// Read the required contexts of `base` from classic branch protection and
3355/// from rulesets, once, for a stop reason. Organisation-level rulesets that
3356/// these two endpoints do not list are missed. Any unreadable source makes the
3357/// whole answer `None`: a partial set would read as a complete one.
3358async fn required_contexts_of(repo: &Path, base: &str) -> Option<BTreeSet<String>> {
3359    let enc = encode_path_segment(base);
3360    let mut all = BTreeSet::new();
3361    // Classic protection answers 404 for an unprotected branch, which is
3362    // "nothing required here", not a failure to read.
3363    let classic = gh(
3364        repo,
3365        &[
3366            "api".to_owned(),
3367            format!("repos/{{owner}}/{{repo}}/branches/{enc}/protection/required_status_checks"),
3368        ],
3369    )
3370    .await
3371    .ok()?;
3372    if classic.0 {
3373        all.extend(parse_classic_required(&classic.1)?);
3374    } else if !classic.1.contains("404") {
3375        return None;
3376    }
3377    let rules = gh(
3378        repo,
3379        &[
3380            "api".to_owned(),
3381            format!("repos/{{owner}}/{{repo}}/rules/branches/{enc}"),
3382        ],
3383    )
3384    .await
3385    .ok()?;
3386    if !rules.0 {
3387        return None;
3388    }
3389    all.extend(parse_ruleset_required(&rules.1)?);
3390    Some(all)
3391}
3392
3393/// `required_status_checks` of classic protection: `contexts` plus the
3394/// `checks[].context` form.
3395fn parse_classic_required(json: &str) -> Option<BTreeSet<String>> {
3396    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3397    let mut out = BTreeSet::new();
3398    for c in v.get("contexts")?.as_array()? {
3399        out.insert(c.as_str()?.to_owned());
3400    }
3401    for c in v
3402        .get("checks")
3403        .and_then(|c| c.as_array())
3404        .into_iter()
3405        .flatten()
3406    {
3407        if let Some(name) = c.get("context").and_then(|n| n.as_str()) {
3408            out.insert(name.to_owned());
3409        }
3410    }
3411    Some(out)
3412}
3413
3414/// `rules/branches/<base>`: every `required_status_checks` rule's contexts.
3415fn parse_ruleset_required(json: &str) -> Option<BTreeSet<String>> {
3416    let v: serde_json::Value = serde_json::from_str(json).ok()?;
3417    let mut out = BTreeSet::new();
3418    for rule in v.as_array()? {
3419        if rule.get("type").and_then(|t| t.as_str()) != Some("required_status_checks") {
3420            continue;
3421        }
3422        let checks = rule
3423            .pointer("/parameters/required_status_checks")?
3424            .as_array()?;
3425        for c in checks {
3426            out.insert(c.get("context")?.as_str()?.to_owned());
3427        }
3428    }
3429    Some(out)
3430}
3431
3432/// Is the observation older than the commit a fix round pushed?
3433///
3434/// The forge takes a few seconds to move the pull request to a new head and
3435/// attach that head's check runs, and until it has, the rollup is the previous
3436/// head's - all green, which is exactly what a merge decision must not read.
3437/// An absent or different head counts as not yet: guessing "close enough"
3438/// would reopen the hole.
3439fn awaiting_new_head(awaiting: Option<&str>, observed: &str) -> bool {
3440    awaiting.is_some_and(|want| !observed.eq_ignore_ascii_case(want))
3441}
3442
3443/// The commit an observation may be decided on, or `None` while it cannot be
3444/// trusted to describe one.
3445///
3446/// `None` when a pushed commit is awaited and the pull request is not on it,
3447/// when the head is unreadable, or when the rollup (`statusCheckRollup` is the
3448/// last commit's) is not the head's: that is the previous commit's checks. The
3449/// caller re-polls on `None`. A rollup that cannot be read (including one
3450/// longer than a page) leaves `rollup_head` empty, so the wait runs to
3451/// [`WAIT_CEILING`] and stops - a safe failure, never a merge.
3452fn bound_head<'a>(
3453    seen_head: &'a str,
3454    rollup_head: &str,
3455    awaiting: Option<&str>,
3456) -> Option<&'a str> {
3457    if seen_head.is_empty()
3458        || awaiting_new_head(awaiting, seen_head)
3459        || !rollup_head.eq_ignore_ascii_case(seen_head)
3460    {
3461        return None;
3462    }
3463    Some(seen_head)
3464}
3465
3466/// What a refused `gh pr merge` means.
3467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3468enum Refused {
3469    /// The branch policy is not satisfied *yet*: go back to waiting.
3470    Pending,
3471    /// Checks have settled and the merge state still says no, seen for the
3472    /// first time. The forge updates `mergeStateStatus` a moment after the last
3473    /// check finishes, so one more look is allowed before believing it.
3474    Recheck,
3475    /// Nothing is in flight and the policy still refuses: a person has to
3476    /// supply what it asks for (a review, say).
3477    Final,
3478}
3479
3480/// Judged from the pull request's state after the refusal, never from the
3481/// refusal's wording, which belongs to the forge and changes.
3482fn classify_refusal(
3483    after: Option<&Seen>,
3484    rechecked: bool,
3485    observed_head: &str,
3486    ci: CiExpectation,
3487) -> Refused {
3488    let Some(after) = after else {
3489        // Unreadable is not evidence of anything; the next loop reads again.
3490        return Refused::Pending;
3491    };
3492    if after.pr.state != PrLifecycle::Open {
3493        return Refused::Final;
3494    }
3495    // The branch moved after it was observed (the forge refuses a merge pinned
3496    // to the old commit): not a verdict on anything, look again.
3497    if !after.head.eq_ignore_ascii_case(observed_head) {
3498        return Refused::Pending;
3499    }
3500    // The same binding the loop applies: checks of another commit say nothing.
3501    if bound_head(&after.head, &after.rollup_head, None).is_none() {
3502        return Refused::Pending;
3503    }
3504    let state = after.merge_state.to_ascii_uppercase();
3505    if (ci == CiExpectation::Expected
3506        && matches!(after.pr.checks, Checks::Pending | Checks::Unknown))
3507        || state.is_empty()
3508        || state == "UNKNOWN"
3509    {
3510        return Refused::Pending;
3511    }
3512    if rechecked {
3513        Refused::Final
3514    } else {
3515        Refused::Recheck
3516    }
3517}
3518
3519/// Take auto-merge back from the forge and forget that it was armed.
3520///
3521/// `Err` carries the forge's message: the caller must not push or move on, as
3522/// an armed merge left behind could still fire for a commit nobody approved.
3523async fn disarm<F: Forge>(
3524    forge: &F,
3525    state: &mut RunState,
3526    repo: &Path,
3527    number: u64,
3528) -> std::result::Result<(), String> {
3529    let argv = disable_automerge_argv(number);
3530    let out = {
3531        let merge_lock = repo_merge_lock(repo);
3532        let _merge_slot = merge_lock.lock().await;
3533        forge.merge(repo, &argv).await
3534    };
3535    match out {
3536        Ok((true, _)) => {
3537            state.land_armed_head = None;
3538            state.event("land", "auto-merge disabled");
3539            state.save().map_err(|e| format!("{e:#}"))?;
3540            Ok(())
3541        }
3542        Ok((false, msg)) => Err(msg),
3543        Err(e) => Err(format!("{e:#}")),
3544    }
3545}
3546
3547/// [`stop`], first taking back an armed auto-merge so a pull request magi
3548/// gave up on does not merge on its own later. A failure to disarm is added
3549/// to the reason rather than hiding it.
3550async fn stop_disarmed<F: Forge>(
3551    forge: &F,
3552    state: &mut RunState,
3553    repo: &Path,
3554    pr: &PrState,
3555    why: &str,
3556) -> Result<()> {
3557    if state.land_armed_head.is_none() {
3558        return stop(state, repo, pr, why).await;
3559    }
3560    match disarm(forge, state, repo, pr.number).await {
3561        Ok(()) => stop(state, repo, pr, why).await,
3562        Err(e) => {
3563            let why = format!("{why} (auto-merge could not be disabled and may still fire: {e})");
3564            stop(state, repo, pr, &why).await
3565        }
3566    }
3567}
3568
3569/// Check the persisted card again around awaits that can outlive an owner tap.
3570fn owner_requested_stop(state: &RunState) -> Result<bool> {
3571    let Some(binding) = &state.land_approval else {
3572        return Ok(false);
3573    };
3574    let q = ask::Questions::open().get(&binding.question)?;
3575    Ok(q.run == state.id
3576        && q.node == APPROVAL_NODE
3577        && q.resolution()
3578            .as_deref()
3579            .is_some_and(crate::merge_decision::stops))
3580}
3581
3582/// Read stop choices before checks, repair or rebase. These are about stopping
3583/// ownership, so a changed head never cancels them as it cancels merge consent.
3584async fn owner_stop<F: Forge>(state: &mut RunState, pr: &mut PrState, forge: &F) -> Result<bool> {
3585    use crate::merge_decision as md;
3586    let Some(binding) = &state.land_approval else {
3587        return Ok(false);
3588    };
3589    let q = ask::Questions::open().get(&binding.question)?;
3590    let Some(choice) = q.resolution().filter(|c| md::stops(c)) else {
3591        return Ok(false);
3592    };
3593    if q.run != state.id || q.node != APPROVAL_NODE {
3594        bail!("stop decision names another run");
3595    }
3596    let target = q.merge_target.clone().unwrap_or_else(|| md::Target {
3597        pr: pr.url.clone(),
3598        head: binding.head.clone(),
3599        language: state.config.graph.language.clone(),
3600    });
3601    if target.pr != pr.url {
3602        bail!("stop decision names another PR");
3603    }
3604    if state.merge_decision.is_none() {
3605        state.merge_decision = Some(md::Decision {
3606            question: q.id.clone(),
3607            target,
3608            choice: choice.clone(),
3609            closed: false,
3610            replacement_run: None,
3611            replacement_pr: None,
3612        });
3613        state.save()?;
3614    }
3615    let repo = state.repo.clone();
3616    // Always disable, including arms left by an older driver.
3617    if let Err(e) = disarm(forge, state, &repo, pr.number).await {
3618        stop(state, &repo, pr, &format!("owner stop requested, but auto-merge could not be disabled: {e}; no repair or replacement started")).await?;
3619        return Ok(true);
3620    }
3621    if choice.eq_ignore_ascii_case(HOLD) {
3622        stop(
3623            state,
3624            &repo,
3625            pr,
3626            "owner held the decision: PR left open; no further repair or merge work",
3627        )
3628        .await?;
3629        return Ok(true);
3630    }
3631    if md::selected_agent(&choice).is_some()
3632        || state
3633            .origin
3634            .as_ref()
3635            .and_then(|o| o.task.as_ref())
3636            .is_some()
3637    {
3638        let owners: Vec<_> = crate::queue::Queue::open()
3639            .list()
3640            .into_iter()
3641            .filter(|task| task.runs.last() == Some(&state.id))
3642            .collect();
3643        let owner = (owners.len() == 1).then(|| &owners[0]);
3644        if !owner.is_some_and(|task| {
3645            task.runs.last() == Some(&state.id)
3646                && (!task
3647                    .operator_decided_at
3648                    .is_some_and(|at| at > q.answered_at.unwrap_or(q.asked_at))
3649                    || task.resumption.as_ref().is_some_and(|release| {
3650                        release.question_id.as_deref() == Some(q.id.as_str())
3651                            && task.operator_decided_at == Some(release.at)
3652                    }))
3653        }) {
3654            stop(state, &repo, pr, "replacement requires the current original queue task owner and an unsuperseded answer; PR left open").await?;
3655            return Ok(true);
3656        }
3657    }
3658    if pr.state == PrLifecycle::Merged {
3659        stop(
3660            state,
3661            &repo,
3662            pr,
3663            "PR already merged; withdrawal/replacement cannot be applied",
3664        )
3665        .await?;
3666        return Ok(true);
3667    }
3668    let comment = format!(
3669        "{MARKER}\nOwner decision on question {}: {}. Previous attempt: `{}`. Original instruction and review history remain in the task record.",
3670        q.id, choice, state.id
3671    );
3672    if pr.state == PrLifecycle::Open {
3673        if let Err(e) = forge.close(&repo, &pr.url, &comment).await {
3674            stop(
3675                state,
3676                &repo,
3677                pr,
3678                &format!(
3679                    "owner requested closure but it failed: {e}; task held, no replacement started"
3680                ),
3681            )
3682            .await?;
3683            return Ok(true);
3684        }
3685    }
3686    let confirmed = forge.view(&repo, &pr.url).await?;
3687    if confirmed.pr.state != PrLifecycle::Closed {
3688        stop(
3689            state,
3690            &repo,
3691            pr,
3692            "PR closure was not confirmed; task held, no replacement started",
3693        )
3694        .await?;
3695        return Ok(true);
3696    }
3697    *pr = confirmed.pr;
3698    state.merge_decision.as_mut().unwrap().closed = true;
3699    if let Some(record) = &mut state.pr {
3700        record.state = "closed".into();
3701    }
3702    state.status = RunStatus::Superseded;
3703    state.merge = Some(MergeOutcome {
3704        mode: MergeMode::Pr,
3705        ok: false,
3706        detail: format!("owner decision: {choice}; PR closure confirmed"),
3707        empty: false,
3708    });
3709    state.event(
3710        "land",
3711        format!(
3712            "{} applied: PR closure confirmed; old execution retired",
3713            choice
3714        ),
3715    );
3716    state.save()?;
3717    Ok(true)
3718}
3719
3720/// Keep an unstarted verification resumable without charging a task attempt.
3721fn check_landing_resources(
3722    state: &mut RunState,
3723    node: &str,
3724    outcomes: &[crate::run::CommandOutcome],
3725) -> Result<()> {
3726    if let Some(blocked) = outcomes.iter().find(|outcome| outcome.resource_blocked) {
3727        let detail = format!("{}: {}", blocked.command, blocked.output_tail);
3728        state
3729            .resource_block
3730            .get_or_insert_with(|| crate::run::ResourceBlock {
3731                cause: crate::run::ResourceBlockCause::Other,
3732                node: node.into(),
3733                kind: "shared build cache".into(),
3734                detail: detail.clone(),
3735                at: jiff::Timestamp::now(),
3736            });
3737        state.event("resource", &detail);
3738        state.save()?;
3739        bail!("landing verification could not start: {detail}");
3740    }
3741    Ok(())
3742}
3743
3744/// Gate the local winner and publish that exact SHA, retaining the saved lease.
3745/// A push completed before a crash is recoverable only when its SHA was gated.
3746async fn publish_landing_head<F: Forge>(
3747    state: &mut RunState,
3748    observed: &str,
3749    pr_url: &str,
3750    forge: &F,
3751) -> Result<String> {
3752    crate::pr_head::ensure_owner(state)?;
3753    let winner = state.winner().cloned().context("landing has no winner")?;
3754    let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3755    let expected = state.pr_pushed_head.as_deref().unwrap_or(observed);
3756    anyhow::ensure!(
3757        observed == expected
3758            || (observed == head
3759                && state.gate_head.as_deref() == Some(&head)
3760                && state.gate_allows_progress()),
3761        "public head moved outside this run; refusing to overwrite it"
3762    );
3763    anyhow::ensure!(
3764        git::is_clean(&winner.worktree).await?,
3765        "landing worktree is dirty"
3766    );
3767    if state.gate_head.as_deref() != Some(&head)
3768        || !state.gate_ran
3769        || !state.gate_commands_allow_progress()
3770    {
3771        state.reconcile_verification_head(&head);
3772        state.gate.clear();
3773        state.gate_head = None;
3774        state.gate_ran = false;
3775        state.pre_gate.clear();
3776        state.pre_gate_commit = None;
3777        state.save()?;
3778        let inherited = crate::repo_lock::WaitPolicy::current();
3779        let wait = crate::repo_lock::WaitPolicy::new(
3780            Duration::from_secs(state.config.graph.timeout_resource_wait),
3781            move || inherited.cancelled(),
3782        );
3783        let mut outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3784        loop {
3785            if outcomes.iter().any(|outcome| outcome.resource_blocked) {
3786                state.gate = outcomes.clone();
3787                check_landing_resources(state, "gate", &outcomes)?;
3788            }
3789            if outcomes.iter().all(|o| o.ok()) {
3790                break;
3791            }
3792            match Box::pin(crate::graph::repair_landing_gate(state, &winner, &outcomes)).await? {
3793                crate::graph::GateFix::Retry => {
3794                    outcomes = crate::graph::run_landing_gate(state, &winner, &wait).await?;
3795                }
3796                crate::graph::GateFix::Stop => break,
3797                crate::graph::GateFix::Defer => {
3798                    // The repair checks e2e immediately, before the next gate.
3799                    // Preserve a cache wait here as well as below; otherwise
3800                    // the daemon charges an attempt for unstarted verification.
3801                    if let Some(e2e) = &state.gate_repair_e2e {
3802                        let outcomes = e2e.outcomes.clone();
3803                        check_landing_resources(state, "e2e", &outcomes)?;
3804                    }
3805                    bail!("landing gate repair deferred");
3806                }
3807            }
3808        }
3809        state.gate = outcomes;
3810        state.gate_ran = true;
3811        state.save()?;
3812    }
3813    let inherited = crate::repo_lock::WaitPolicy::current();
3814    let wait = crate::repo_lock::WaitPolicy::new(
3815        Duration::from_secs(state.config.graph.timeout_resource_wait),
3816        move || inherited.cancelled(),
3817    );
3818    let checked = state
3819        .gate_head
3820        .clone()
3821        .context("landing gate has no checked SHA")?;
3822    anyhow::ensure!(
3823        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3824            && git::is_clean(&winner.worktree).await?,
3825        "landing tree changed after gate"
3826    );
3827    let e2e = crate::graph::check_gate_repair_e2e(state, &winner, &wait).await?;
3828    if matches!(e2e, crate::graph::GateFix::Defer) {
3829        let outcomes = state
3830            .gate_repair_e2e
3831            .as_ref()
3832            .context("deferred landing e2e has no outcomes")?
3833            .outcomes
3834            .clone();
3835        check_landing_resources(state, "e2e", &outcomes)?;
3836    }
3837    anyhow::ensure!(
3838        matches!(e2e, crate::graph::GateFix::Retry),
3839        "post-repair e2e has not passed on the landing head; nothing published"
3840    );
3841    anyhow::ensure!(
3842        git::rev_parse(&winner.worktree, "HEAD").await? == checked
3843            && git::is_clean(&winner.worktree).await?,
3844        "landing tree changed during post-repair e2e"
3845    );
3846    anyhow::ensure!(
3847        crate::graph::verify_current_head(state, &winner, &wait).await?,
3848        "current head e2e has not passed; nothing published"
3849    );
3850    anyhow::ensure!(
3851        state.gate_allows_progress(),
3852        "landing gate failed; nothing published"
3853    );
3854    crate::pr_head::ensure_owner(state)?;
3855    if owner_requested_stop(state)? {
3856        bail!("owner requested stop before publication");
3857    }
3858    let current = forge.view(&state.repo, pr_url).await?;
3859    anyhow::ensure!(
3860        current.pr.state == PrLifecycle::Open,
3861        "PR closed or merged while gating; nothing published"
3862    );
3863    anyhow::ensure!(
3864        current.head == observed || current.head == checked,
3865        "PR head changed while gating; nothing published"
3866    );
3867    anyhow::ensure!(
3868        crate::foreign_checkout::write_back(state, &checked).await?,
3869        "foreign checkout delivery is blocked; see the run events"
3870    );
3871    let public = state.pr_branch().unwrap_or(&winner.branch).to_owned();
3872    let lease = format!("--force-with-lease=refs/heads/{public}:{observed}");
3873    let refspec = format!("{checked}:refs/heads/{public}");
3874    let lock = repo_merge_lock(&state.repo);
3875    let _slot = lock.lock().await;
3876    let pushed = git::git_raw(
3877        &winner.worktree,
3878        &["push", &lease, &state.config.merge.remote, &refspec],
3879    )
3880    .await?;
3881    anyhow::ensure!(
3882        pushed.ok(),
3883        "publishing gated landing head failed: {}",
3884        pushed.stderr
3885    );
3886    state.pr_pushed_head = Some(checked.clone());
3887    state.land_sync_pending = false;
3888    state.save()?;
3889    Ok(checked)
3890}
3891
3892/// A scratch rebase moves the winner ref before its index and files. Recover
3893/// only a tree proven unchanged since the saved start; never discard edits.
3894async fn restore_landing_worktree(state: &mut RunState) -> Result<()> {
3895    let winner = state.winner().cloned().context("landing has no winner")?;
3896    let inherited = crate::repo_lock::WaitPolicy::current();
3897    let wait = crate::repo_lock::WaitPolicy::new(
3898        Duration::from_secs(state.config.graph.timeout_resource_wait),
3899        move || inherited.cancelled(),
3900    );
3901    let _coordination = crate::repo_lock::SeatLease::operation_with(
3902        &winner.worktree,
3903        state.config.coordination.as_ref(),
3904        &wait,
3905    )
3906    .await?;
3907    if !git::is_clean(&winner.worktree).await? {
3908        let from = state
3909            .land_rebase_from
3910            .as_deref()
3911            .context("landing worktree is dirty")?;
3912        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3913        anyhow::ensure!(head != from, "landing worktree is dirty");
3914        for args in [
3915            vec!["diff", "--cached", "--quiet", from],
3916            vec!["diff", "--quiet"],
3917        ] {
3918            anyhow::ensure!(
3919                git::git_raw(&winner.worktree, &args).await?.ok(),
3920                "landing worktree has edits beyond the interrupted rebase"
3921            );
3922        }
3923        // Preserve build artifacts and local files, including ignored ones.
3924        // read-tree can overwrite ignored paths, so reject collisions with
3925        // the new tree (including file/directory conflicts) before updating.
3926        let untracked = git::git(&winner.worktree, &["ls-files", "--others", "-z"]).await?;
3927        let tracked = git::git(
3928            &winner.worktree,
3929            &["ls-tree", "-r", "--name-only", "-z", "HEAD"],
3930        )
3931        .await?;
3932        for local in untracked.split('\0').filter(|p| !p.is_empty()) {
3933            anyhow::ensure!(
3934                !tracked.split('\0').filter(|p| !p.is_empty()).any(|path| {
3935                    path == local
3936                        || path
3937                            .strip_prefix(local)
3938                            .is_some_and(|rest| rest.starts_with('/'))
3939                        || local
3940                            .strip_prefix(path)
3941                            .is_some_and(|rest| rest.starts_with('/'))
3942                }),
3943                "landing worktree has an untracked or ignored path conflicting with HEAD: {local}"
3944            );
3945        }
3946        git::git(&winner.worktree, &["read-tree", "-m", "-u", "HEAD"]).await?;
3947    }
3948    if let (Some(checkout), Some(from)) = (&state.foreign_checkout, &state.land_rebase_from) {
3949        anyhow::ensure!(
3950            git::is_ancestor(&winner.worktree, &checkout.synced_head, from).await,
3951            "landing synchronization started outside the foreign checkout history"
3952        );
3953        if !git::is_ancestor(&winner.worktree, from, "HEAD").await {
3954            // The rebase's tree already includes the base and the fixer's
3955            // resolutions. Retain that exact tree, but join the pre-sync
3956            // history so delivery remains fast-forward. Keep the checkpoint
3957            // until this succeeds: a crash after rebase (or after this merge)
3958            // must recover through this same idempotent path before gating.
3959            git::git(
3960                &winner.worktree,
3961                &[
3962                    "-c",
3963                    "core.hooksPath=/dev/null",
3964                    "merge",
3965                    "--strategy=ours",
3966                    "--no-ff",
3967                    "--no-autostash",
3968                    "--no-edit",
3969                    "-m",
3970                    "Merge pre-sync history into the repaired landing tree",
3971                    from,
3972                ],
3973            )
3974            .await?;
3975        }
3976    }
3977    crate::worktree_setup::prepare(&state.config, &state.repo, &winner.worktree).await?;
3978    state.land_rebase_from = None;
3979    state.save()?;
3980    Ok(())
3981}
3982
3983async fn rebase_landing_worktree(
3984    state: &mut RunState,
3985    onto: &str,
3986) -> Result<crate::rebase::Rebased> {
3987    restore_landing_worktree(state).await?;
3988    let winner = state.winner().cloned().context("landing has no winner")?;
3989    state.land_sync_pending = true;
3990    state.land_rebase_from = Some(git::rev_parse(&winner.worktree, "HEAD").await?);
3991    state.save()?;
3992    let scratch = state.dir().join("rebase");
3993    let result = crate::rebase::rebase_with_fixer(state, &scratch, &winner.branch, onto).await?;
3994    if result == crate::rebase::Rebased::Applied {
3995        restore_landing_worktree(state).await?;
3996    }
3997    Ok(result)
3998}
3999
4000/// Reconcile a stopped landing before changing its branch. This also handles
4001/// old records whose graph status or merge detail no longer identifies land.
4002async fn resume_landing_head<F: Forge>(
4003    state: &mut RunState,
4004    observed: &str,
4005    pr_url: &str,
4006    forge: &F,
4007) -> Result<String> {
4008    crate::pr_head::ensure_owner(state)?;
4009    let winner = state.winner().cloned().context("landing has no winner")?;
4010    let local = git::rev_parse(&winner.worktree, "HEAD").await?;
4011    let expected = state
4012        .pr_pushed_head
4013        .as_deref()
4014        .or(state.gate_head.as_deref())
4015        .context("missing public head lease")?;
4016    anyhow::ensure!(
4017        observed == expected
4018            || (observed == local
4019                && ((state.gate_head.as_deref() == Some(&local) && state.gate_allows_progress())
4020                    || git::is_ancestor(&winner.worktree, expected, &local).await)),
4021        "public head moved outside this run; refusing to sync"
4022    );
4023    // An operator may have extended and published the winner while approval
4024    // was pending. Matching local/public history can be adopted as the lease,
4025    // but publish_landing_head must gate its new SHA before proceeding. A
4026    // remote-only or divergent update still cannot be overwritten.
4027    // Save reconciliation before rebasing again; a prior push may have reached
4028    // the remote immediately before the previous driver's last save.
4029    state.pr_pushed_head = Some(observed.to_owned());
4030    state.save()?;
4031    restore_landing_worktree(state).await?;
4032    let remote = state.config.merge.remote.clone();
4033    let base = state.base_branch.clone();
4034    let fetched = git::fetch(&state.repo, &remote, &base).await?;
4035    anyhow::ensure!(
4036        fetched.ok(),
4037        "cannot fetch landing base: {}",
4038        fetched.stderr
4039    );
4040    let onto = format!("{remote}/{base}");
4041    let tip = git::rev_parse(&state.repo, &onto).await?;
4042    if !git::is_ancestor(&state.repo, &tip, &local).await {
4043        match rebase_landing_worktree(state, &onto).await? {
4044            crate::rebase::Rebased::Applied => {}
4045            crate::rebase::Rebased::Stopped(why) => bail!("landing rebase stopped: {why}"),
4046        }
4047    }
4048    publish_landing_head(state, observed, pr_url, forge).await
4049}
4050
4051async fn land_with<F: Forge>(state: &mut RunState, pr_url: &str, forge: &F) -> Result<PrState> {
4052    crate::pr_head::ensure_owner(state)?;
4053    state.land_unrelated = None;
4054    let reopening_hold = crate::merge_decision::resumable_hold(state);
4055    let repo = state.repo.clone();
4056    let budget = state.config.graph.land_rounds;
4057    let mut round = 0usize;
4058    // Counted apart from `round`: a rebase is not a fix, and a base that
4059    // moved is not the change's fault.
4060    let mut rebases = 0usize;
4061    let ci = state.config.merge.ci.expectation();
4062    if ci == CiExpectation::Absent {
4063        state.event(
4064            "land",
4065            "CI not expected ([merge] ci = \"none\"); check status ignored",
4066        );
4067    }
4068    let mut waited = Duration::ZERO;
4069    // Comment bodies the fixer has already been shown. A comment is
4070    // outstanding until it has been handed over once; after that it is a
4071    // recorded decision, not an open question, and re-feeding it would loop the
4072    // budget away on a comment the fixer already declined with an argument.
4073    let mut shown: BTreeSet<String> = BTreeSet::new();
4074    // Await the last successfully published head, including across resumes.
4075    let mut awaiting_head: Option<String> = state.pr_pushed_head.clone();
4076    // Whether a settled-checks refusal has already been given its one re-look.
4077    let mut rechecked = false;
4078
4079    // Marks the run resumable through exactly this function, not through a
4080    // fresh competition: `RunStatus::resumable` excludes only `Merged`,
4081    // `Ready` and `Failed`, and `merge`'s own re-entry guard looks for this
4082    // status specifically to know a resumed run belongs back in `land`
4083    // rather than at a second `gh pr create`. Set on every entry - fresh or
4084    // resumed - because a resume that parked here again must keep reading
4085    // `Landing`, not whatever a first pass through `merge` left behind.
4086    state.status = RunStatus::Landing;
4087    state.event("land", format!("watching {pr_url}"));
4088    state.save()?;
4089
4090    // An armed merge recorded by an earlier pass may or may not have reached
4091    // the forge (the record is written before the call). It is taken back on
4092    // the first observation, where a pull request is known to stop with.
4093    let mut resumed_armed = state.land_armed_head.is_some();
4094
4095    loop {
4096        let seen = forge.view(&repo, pr_url).await?;
4097        let mut pr = seen.pr.clone();
4098        pr.review_comments.retain(|c| !shown.contains(&c.body));
4099        state.pr = Some(crate::run::PrRecord {
4100            url: pr.url.clone(),
4101            number: pr.number,
4102            state: pr.state.as_str().to_owned(),
4103            checks: pr.checks.as_str().to_owned(),
4104            round,
4105            rounds: budget,
4106            red_at_merge: Vec::new(),
4107        });
4108        state.save()?;
4109
4110        if pr.state == PrLifecycle::Open {
4111            state.observe_branch_owner_holds(&seen.head).await;
4112            state.save()?;
4113        }
4114        crate::pr_head::ensure_owner(state)?;
4115        if reopening_hold {
4116            anyhow::ensure!(
4117                pr.state == PrLifecycle::Open,
4118                "held PR is no longer open; cannot request a fresh decision"
4119            );
4120            // An explicit resume authorizes a fresh question only. Preserve the
4121            // answered card, and never enter repair/rebase/merge on this pass.
4122            disarm(forge, state, &repo, pr.number)
4123                .await
4124                .map_err(anyhow::Error::msg)?;
4125            let previous = state.merge_decision.take();
4126            let binding = state.land_approval.take();
4127            state.config.graph.land_approval = true;
4128            let subject = merge_subject(
4129                crate::graph::landing_title(state, &seen.title),
4130                &crate::graph::landing_subject_source(state),
4131            );
4132            let contested = contested_to_ask(state);
4133            if let Err(error) =
4134                approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await
4135            {
4136                state.merge_decision = previous;
4137                state.land_approval = binding;
4138                return Err(error);
4139            }
4140            state.end_pass_parked(crate::run::ParkCause::Question);
4141            state.parked = true;
4142            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()));
4143            state.save()?;
4144            return Ok(pr);
4145        }
4146        if owner_stop(state, &mut pr, forge).await? {
4147            return Ok(pr);
4148        }
4149
4150        if std::mem::take(&mut resumed_armed) && pr.state == PrLifecycle::Open {
4151            // An approval already given for the same head is reused, so
4152            // nothing is asked twice. A failed disable
4153            // stops the run with the record kept: pushing on could change the
4154            // head under an arm that is still live.
4155            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4156                let why = format!(
4157                    "a previous pass may have armed auto-merge and it could not be disabled \
4158                     on resume: {e}"
4159                );
4160                stop(state, &repo, &pr, &why).await?;
4161                return Ok(pr);
4162            }
4163        }
4164
4165        // The head moved away from the one auto-merge was armed on, by
4166        // someone other than this loop. The arm is pinned and would refuse to
4167        // merge the new commit, but the approval was for the old one: take it
4168        // back and go through the normal path again.
4169        if pr.state == PrLifecycle::Open
4170            && !seen.head.is_empty()
4171            && state
4172                .land_armed_head
4173                .as_deref()
4174                .is_some_and(|armed| !armed.eq_ignore_ascii_case(&seen.head))
4175        {
4176            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4177                let why = format!(
4178                    "the head moved while auto-merge was armed and it could not be disabled: {e}"
4179                );
4180                stop(state, &repo, &pr, &why).await?;
4181                return Ok(pr);
4182            }
4183        }
4184
4185        if state.land_sync_pending && pr.state == PrLifecycle::Open {
4186            // Disarming can outlive an owner answer. Consume it before our
4187            // own rebase invalidates the head shown on the decision card.
4188            if owner_stop(state, &mut pr, forge).await? {
4189                return Ok(pr);
4190            }
4191            let repair_answer = state.land_approval.as_ref().is_some_and(|binding| {
4192                binding.head.eq_ignore_ascii_case(&seen.head)
4193                    && ask::Questions::open()
4194                        .get(&binding.question)
4195                        .is_ok_and(|q| {
4196                            q.run == state.id
4197                                && q.node == APPROVAL_NODE
4198                                && q.status == ask::QuestionStatus::Answered
4199                                && matches!(approval(q.resolution().as_deref()), Approval::Repair)
4200                        })
4201            });
4202            if repair_answer {
4203                let subject = merge_subject(
4204                    crate::graph::landing_title(state, &seen.title),
4205                    &crate::graph::landing_subject_source(state),
4206                );
4207                let contested = contested_to_ask(state);
4208                if matches!(
4209                    approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head).await?,
4210                    ApprovalGate::Repair
4211                ) {
4212                    if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4213                        state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4214                        state.save()?;
4215                    }
4216                    return Ok(pr);
4217                }
4218            }
4219            let synced = resume_landing_head(state, &seen.head, pr_url, forge).await;
4220            // A stop detected before publication must apply its durable
4221            // decision, even when verification returned an error meanwhile.
4222            if owner_stop(state, &mut pr, forge).await? {
4223                return Ok(pr);
4224            }
4225            let head = synced?;
4226            awaiting_head = Some(head);
4227            waited = Duration::ZERO;
4228            rechecked = false;
4229            // Read CI and approvals afresh, never from the pre-push observation.
4230            continue;
4231        }
4232
4233        if pr.state == PrLifecycle::Open {
4234            if bound_head(&seen.head, &seen.rollup_head, awaiting_head.as_deref()).is_none() {
4235                if waited >= WAIT_CEILING {
4236                    let want = awaiting_head.as_deref().unwrap_or_default();
4237                    let why = format!(
4238                        "the pull request's checks were still not about one readable head after \
4239                         {} minutes (expected {}, pull request points at {}, checks are for {}); \
4240                         someone may have pushed over it",
4241                        WAIT_CEILING.as_secs() / 60,
4242                        if want.is_empty() { "any" } else { want },
4243                        if seen.head.is_empty() {
4244                            "nothing readable"
4245                        } else {
4246                            &seen.head
4247                        },
4248                        if seen.rollup_head.is_empty() {
4249                            "nothing readable"
4250                        } else {
4251                            &seen.rollup_head
4252                        },
4253                    );
4254                    stop_disarmed(forge, state, &repo, &pr, &why).await?;
4255                    return Ok(pr);
4256                }
4257                waited += POLL;
4258                forge.poll().await;
4259                continue;
4260            }
4261            // Reset once, when the awaited head first shows up; resetting on
4262            // every re-observation would let a standing refusal wait forever.
4263            if awaiting_head.take().is_some() {
4264                // The checks now being read belong to the new head; give them
4265                // the same grace a fresh pull request gets.
4266                waited = Duration::ZERO;
4267            }
4268        }
4269
4270        // The merge-state grace is head-bound and durable. Reruns reset the
4271        // checks clock, but must not extend this deadline across resumes.
4272        let decision_wait = if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid {
4273            let first = state
4274                .land_unsaid
4275                .get_or_insert_with(|| (seen.head.clone(), forge.now()));
4276            if !first.0.eq_ignore_ascii_case(&seen.head) {
4277                *first = (seen.head.clone(), forge.now());
4278            }
4279            let elapsed = Duration::from_secs(forge.now().saturating_sub(first.1).max(0) as u64);
4280            state.save()?;
4281            elapsed
4282        } else {
4283            waited
4284        };
4285        let proposed = decide_with(&pr, round, budget, decision_wait, ci);
4286        if pr.checks == Checks::Red && pr.blocking == Blocking::Unsaid && proposed == Step::Wait {
4287            if waited >= WAIT_CEILING {
4288                stop_disarmed(
4289                    forge,
4290                    state,
4291                    &repo,
4292                    &pr,
4293                    "merge state remained unreadable; refusing to merge on a guess",
4294                )
4295                .await?;
4296                return Ok(pr);
4297            }
4298            waited += POLL;
4299            forge.poll().await;
4300            continue;
4301        }
4302
4303        // A failed workflow run is rerun once, judged by its own run rather
4304        // than by the rollup's aggregate: another run still pending, or a fix
4305        // budget already spent, must not keep a flaky failure from its one
4306        // rerun. It spends no round and no attempt.
4307        if ci == CiExpectation::Expected
4308            && pr.state == PrLifecycle::Open
4309            && state.config.graph.rerun_flaky_checks
4310        {
4311            let include_optional = pr.checks == Checks::Red
4312                && matches!(proposed, Step::Fix { .. } | Step::GiveUp { .. });
4313            let failing = rerunnable_with(&seen.contexts, include_optional);
4314            let now = forge.now();
4315            match rerun_step(&failing, state.land_reruns.as_ref(), &seen.head, now) {
4316                RerunStep::Rerun(runs) => {
4317                    let rec = state.land_reruns.get_or_insert_with(Default::default);
4318                    if !rec.head.eq_ignore_ascii_case(&seen.head) {
4319                        *rec = LandReruns {
4320                            head: seen.head.clone(),
4321                            runs: BTreeMap::new(),
4322                        };
4323                    }
4324                    for run in &runs {
4325                        rec.runs.insert(run.clone(), now);
4326                    }
4327                    // Durable before the call: a restart must not
4328                    // rerun the same run twice. A save that fails
4329                    // means no rerun.
4330                    state.save()?;
4331                    let mut any = false;
4332                    for run in &runs {
4333                        let names = failing
4334                            .iter()
4335                            .filter(|(_, r)| r.as_deref() == Some(run))
4336                            .map(|(l, _)| l.as_str())
4337                            .collect::<Vec<_>>()
4338                            .join(", ");
4339                        match forge.rerun(&repo, run).await {
4340                            Ok(()) => {
4341                                any = true;
4342                                state.event(
4343                                    "land",
4344                                    format!("reran failed check(s) {names} (run {run})"),
4345                                );
4346                            }
4347                            // Stays recorded: never a retry loop.
4348                            Err(e) => state.event(
4349                                "land",
4350                                format!("could not rerun check(s) {names} (run {run}): {e:#}"),
4351                            ),
4352                        }
4353                    }
4354                    state.save()?;
4355                    if any {
4356                        waited = Duration::ZERO;
4357                        forge.poll().await;
4358                        continue;
4359                    }
4360                }
4361                RerunStep::Grace => {
4362                    if waited >= WAIT_CEILING {
4363                        let why = format!(
4364                            "checks were still failing after a rerun and {} minutes",
4365                            WAIT_CEILING.as_secs() / 60
4366                        );
4367                        stop(state, &repo, &pr, &why).await?;
4368                        return Ok(pr);
4369                    }
4370                    waited += POLL;
4371                    forge.poll().await;
4372                    continue;
4373                }
4374                RerunStep::Fix => {}
4375            }
4376        }
4377
4378        let step = proposed;
4379        // Armed on exactly this head: the forge is doing the waiting. A
4380        // decision to merge (or keep waiting) is only watched, never re-armed
4381        // and never re-approved; anything else - a red check, a comment, a
4382        // conflict - falls through to its own arm, which disarms first.
4383        let armed_here = state
4384            .land_armed_head
4385            .as_deref()
4386            .is_some_and(|armed| armed.eq_ignore_ascii_case(&seen.head));
4387        if armed_here && matches!(step, Step::Merge | Step::Wait) {
4388            if waited >= WAIT_CEILING {
4389                let required = if seen.base.is_empty() {
4390                    None
4391                } else {
4392                    forge.required_contexts(&repo, &seen.base).await
4393                };
4394                let why = format!(
4395                    "auto-merge was armed on {} but the pull request did not merge within {} \
4396                     minutes ({})",
4397                    seen.head,
4398                    WAIT_CEILING.as_secs() / 60,
4399                    waiting_on(&seen.merge_state, &seen.contexts, required.as_ref())
4400                );
4401                stop_disarmed(forge, state, &repo, &pr, &why).await?;
4402                return Ok(pr);
4403            }
4404            waited += POLL;
4405            forge.poll().await;
4406            continue;
4407        }
4408        if armed_here && !matches!(step, Step::Done { .. }) {
4409            // Not merging or waiting any more: whatever is next may change the
4410            // head or give up, and neither may leave the arm standing.
4411            if let Err(e) = disarm(forge, state, &repo, pr.number).await {
4412                let why = format!("auto-merge could not be disabled: {e}");
4413                stop(state, &repo, &pr, &why).await?;
4414                return Ok(pr);
4415            }
4416        }
4417        if pr.checks == Checks::Red
4418            && matches!(step, Step::Fix { .. } | Step::GiveUp { .. })
4419            && pr.review_comments.is_empty()
4420            && let Some(evidence) = forge.attribution(state, &seen).await
4421            && evidence.unrelated(&seen.head, &pr.failing)
4422        {
4423            if owner_stop(state, &mut pr, forge).await? {
4424                return Ok(pr);
4425            }
4426            // Attribution does not authorize action against an unseen push.
4427            let fresh = forge.view(&repo, pr_url).await?;
4428            if fresh.head != seen.head
4429                || fresh.rollup_head != seen.rollup_head
4430                || fresh.pr.state != PrLifecycle::Open
4431                || fresh.pr.checks != Checks::Red
4432                || fresh.pr.failing != pr.failing
4433                || fresh
4434                    .pr
4435                    .review_comments
4436                    .iter()
4437                    .any(|c| !shown.contains(&c.body))
4438            {
4439                continue;
4440            }
4441            hold_unrelated(state, &pr, evidence).await?;
4442            return Ok(pr);
4443        }
4444        match step {
4445            Step::Wait => {
4446                if waited >= WAIT_CEILING {
4447                    let why = format!(
4448                        "checks were still running after {} minutes",
4449                        WAIT_CEILING.as_secs() / 60
4450                    );
4451                    stop(state, &repo, &pr, &why).await?;
4452                    return Ok(pr);
4453                }
4454                waited += POLL;
4455                forge.poll().await;
4456            }
4457            Step::Done { merged } => {
4458                if merged {
4459                    state.pr_merged_head = (!seen.head.is_empty()).then(|| seen.head.clone());
4460                }
4461                // Auto-merge is pinned to the approved head, but the forge's
4462                // own handling of a later push is not something magi can
4463                // see: if the pull request merged on another commit, say so
4464                // loudly rather than record a clean landing.
4465                if let Some(armed) = state.land_armed_head.take() {
4466                    if merged && !seen.head.is_empty() && !armed.eq_ignore_ascii_case(&seen.head) {
4467                        let msg = format!(
4468                            "{} merged on {} but the owner approved {armed}; review what landed",
4469                            pr.url, seen.head
4470                        );
4471                        tracing::warn!("{msg}");
4472                        state.event("land", msg);
4473                        // Only an arm left by an older build can get here.
4474                        // The wording is fixed so a repeat does not relight
4475                        // the notice.
4476                        crate::notices::raise_with(
4477                            crate::notices::Notice::warn(
4478                                &format!("merged-unapproved-head:{}", state.id),
4479                                "A pull request merged on a commit the owner did not approve; \
4480                                 review what landed",
4481                            )
4482                            .link(crate::notices::Link::Run {
4483                                id: state.id.clone(),
4484                            }),
4485                            &state.config.notify,
4486                        );
4487                    }
4488                }
4489                state.status = if merged {
4490                    RunStatus::Merged
4491                } else {
4492                    RunStatus::Ready
4493                };
4494                let detail = if merged {
4495                    format!("{} was merged", pr.url)
4496                } else {
4497                    format!("{} was closed without merging", pr.url)
4498                };
4499                state.merge = Some(MergeOutcome {
4500                    mode: MergeMode::Pr,
4501                    ok: merged,
4502                    detail: detail.clone(),
4503                    empty: false,
4504                });
4505                state.event("land", detail);
4506                state.save()?;
4507                write_pr_state_through(state, pr.state);
4508                return Ok(pr);
4509            }
4510            Step::Merge => {
4511                state.check_delivery_evidence(&seen.head)?;
4512                let subject = merge_subject(
4513                    crate::graph::landing_title(state, &seen.title),
4514                    &crate::graph::landing_subject_source(state),
4515                );
4516                // The owner sees the panel before the one irreversible step,
4517                // and an unanswered question is a hold: silence never merges.
4518                //
4519                // The dedicated policy includes existing consent and post-repair
4520                // decisions; repair choice availability never grants merge authority.
4521                let contested = contested_to_ask(state);
4522                if !approval_reasons(state).is_empty() {
4523                    match approval_gate(state, &pr, &subject, contested.as_ref(), &seen.head)
4524                        .await?
4525                    {
4526                        ApprovalGate::Approved => {}
4527                        ApprovalGate::Repair => {
4528                            // The driver consumes the saved request under the existing
4529                            // run/task claim. No merge, no second run or task.
4530                            if let Err(why) = disarm(forge, state, &repo, pr.number).await {
4531                                state.event("repair", format!("auto-merge could not be disabled: {why}; repair preflight will retry"));
4532                                state.save()?;
4533                            }
4534                            return Ok(pr);
4535                        }
4536                        ApprovalGate::Held => {
4537                            // Another entry point may have saved a stop after
4538                            // the first read above. Apply its durable action.
4539                            if owner_stop(state, &mut pr, forge).await? {
4540                                return Ok(pr);
4541                            }
4542                            stop(
4543                                state,
4544                                &repo,
4545                                &pr,
4546                                "the owner did not approve the merge (held or unanswered)",
4547                            )
4548                            .await?;
4549                            return Ok(pr);
4550                        }
4551                        // Filed (or still standing from an earlier visit) and
4552                        // not yet answered. Park here rather than wait: the
4553                        // question survives on disk, the daemon hands this
4554                        // run's slot to something else, and a later resume
4555                        // re-enters `land`, finds the same question, and
4556                        // either merges or stops depending on what it says
4557                        // by then.
4558                        ApprovalGate::Pending => {
4559                            state.end_pass_parked(crate::run::ParkCause::Question);
4560                            state.parked = true;
4561                            state.event(
4562                                "land",
4563                                "parked awaiting merge approval - resumes once answered",
4564                            );
4565                            state.save()?;
4566                            return Ok(pr);
4567                        }
4568                    }
4569                }
4570                // Open and past the gate above, so `seen.head` is the commit
4571                // the checks were bound to and the owner approved.
4572                //
4573                // Merge directly, bound to that commit. Auto-merge is not
4574                // armed any more: the forge checks `--match-head-commit` only
4575                // when the request is made, so an arm outlives the head it
4576                // was made for, and a push of another commit whose
4577                // requirements are met first would merge without approval.
4578                // A direct merge is checked by the forge at the moment it
4579                // happens, so only the approved head can land.
4580                let observed_head = seen.head.clone();
4581                // Read the pull request again just before: the state seen
4582                // above can be a moment old.
4583                {
4584                    let fresh = forge.view(&repo, pr_url).await.ok();
4585                    if !direct_merge_is_safe(
4586                        fresh.as_ref(),
4587                        &observed_head,
4588                        &shown,
4589                        round,
4590                        budget,
4591                        waited,
4592                        ci,
4593                    ) {
4594                        if waited >= WAIT_CEILING {
4595                            let why = "the pull request did not settle on the approved head \
4596                                       before it could be merged";
4597                            stop(state, &repo, &pr, why).await?;
4598                            return Ok(pr);
4599                        }
4600                        state.event(
4601                            "land",
4602                            "the pull request changed before merging; looking again",
4603                        );
4604                        waited += POLL;
4605                        forge.poll().await;
4606                        continue;
4607                    }
4608                }
4609                let argv = merge_argv_at(pr.number, &subject, &observed_head);
4610                let out = {
4611                    let merge_lock = repo_merge_lock(&repo);
4612                    let _merge_slot = merge_lock.lock().await;
4613                    crate::pr_head::ensure_owner(state)?;
4614                    if let Some(task_id) = state
4615                        .origin
4616                        .as_ref()
4617                        .and_then(|origin| origin.task.as_ref())
4618                    {
4619                        let task = crate::queue::Queue::open()
4620                            .get(task_id)
4621                            .context("cannot verify the task owner immediately before merge")?;
4622                        if task.runs.last() != Some(&state.id)
4623                            || task.operator_held()
4624                            || task.status == crate::queue::TaskStatus::Done
4625                        {
4626                            bail!(
4627                                "task ownership or an operator hold superseded this merge approval"
4628                            );
4629                        }
4630                    }
4631                    if let Some(binding) = &state.land_approval {
4632                        let current = ask::Questions::open().get(&binding.question)?;
4633                        if merge_consent_superseded(state, &current)?
4634                            || current.run != state.id
4635                            || !binding.head.eq_ignore_ascii_case(&observed_head)
4636                            || approval(current.resolution().as_deref()) != Approval::Merge
4637                            || current.merge_target.as_ref().is_some_and(|t| {
4638                                t.pr != pr.url || !t.head.eq_ignore_ascii_case(&observed_head)
4639                            })
4640                        {
4641                            bail!(
4642                                "merge decision is stale or no longer authorizes the reviewed head"
4643                            );
4644                        }
4645                    }
4646                    forge.merge(&repo, &argv).await?
4647                };
4648                if out.0 {
4649                    // Exit 0 is not proof of a merge: with a merge queue `gh`
4650                    // enqueues (or reports the pull request already queued)
4651                    // and succeeds while it is still open. Ask the forge; an
4652                    // unreadable answer is not a confirmation either, so it
4653                    // is looked at again rather than recorded as merged.
4654                    let confirmed = forge.view(&repo, pr_url).await.is_ok_and(|c| {
4655                        if c.pr.state == PrLifecycle::Merged {
4656                            state.pr_merged_head = (!c.head.is_empty()).then_some(c.head);
4657                            true
4658                        } else {
4659                            false
4660                        }
4661                    });
4662                    if !confirmed {
4663                        if waited >= WAIT_CEILING {
4664                            let why = "the merge request succeeded but the pull request \
4665                                       could not be confirmed merged after waiting";
4666                            stop(state, &repo, &pr, why).await?;
4667                            return Ok(pr);
4668                        }
4669                        state.event(
4670                            "land",
4671                            "merge accepted but the pull request is not confirmed merged yet; waiting",
4672                        );
4673                        state.save()?;
4674                        waited += POLL;
4675                        forge.poll().await;
4676                        continue;
4677                    }
4678                    pr.state = PrLifecycle::Merged;
4679                    state.status = RunStatus::Merged;
4680                    state.merge = Some(MergeOutcome {
4681                        mode: MergeMode::Pr,
4682                        ok: true,
4683                        detail: format!("gh {}", argv.join(" ")),
4684                        empty: false,
4685                    });
4686                    // The last `state.pr` snapshot is whatever the poll before
4687                    // this merge observed - still `open` - and nothing below
4688                    // refreshes it from GitHub again, so the UI's round rail
4689                    // would otherwise keep animating a merged run forever.
4690                    if let Some(pr_record) = state.pr.as_mut() {
4691                        pr_record.state = pr.state.as_str().to_owned();
4692                    }
4693                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4694                    announce_red_merge(state, &pr).await;
4695                    state.save()?;
4696                    write_pr_state_through(state, pr.state);
4697                    return Ok(pr);
4698                }
4699                let after_seen = forge.view(&repo, pr_url).await.ok();
4700                let after = after_seen.as_ref().map(|s| s.pr.state);
4701                if let Some(outcome) = merged_after_all(&argv, &out.1, after) {
4702                    state.pr_merged_head = after_seen
4703                        .as_ref()
4704                        .map(|s| s.head.clone())
4705                        .filter(|h| !h.is_empty());
4706                    pr.state = PrLifecycle::Merged;
4707                    state.status = RunStatus::Merged;
4708                    state.merge = Some(outcome);
4709                    if let Some(pr_record) = state.pr.as_mut() {
4710                        pr_record.state = pr.state.as_str().to_owned();
4711                    }
4712                    state.event("land", format!("merged {} as `{subject}`", pr.url));
4713                    announce_red_merge(state, &pr).await;
4714                    state.save()?;
4715                    write_pr_state_through(state, pr.state);
4716                    return Ok(pr);
4717                }
4718                let verdict = classify_refusal(after_seen.as_ref(), rechecked, &observed_head, ci);
4719                match verdict {
4720                    Refused::Final => {
4721                        let merge_state = after_seen
4722                            .as_ref()
4723                            .map(|s| s.merge_state.as_str())
4724                            .filter(|m| !m.is_empty())
4725                            .unwrap_or("unknown");
4726                        // Which refusal this was decides what a person has to
4727                        // do about it, so the two are worded apart; both carry
4728                        // the forge's own message.
4729                        let why = format!(
4730                            "the merge was refused: {} (merge state: {merge_state})",
4731                            out.1
4732                        );
4733                        stop(state, &repo, &pr, &why).await?;
4734                        return Ok(pr);
4735                    }
4736                    verdict => {
4737                        // Back to the top, which re-decides and passes the
4738                        // approval gate again, a poll later. `waited` is not
4739                        // reset here: only a pushed fix restarts it, or a
4740                        // standing refusal would wait forever.
4741                        if waited >= WAIT_CEILING {
4742                            let why = format!(
4743                                "the merge was still refused after {} minutes: {}",
4744                                WAIT_CEILING.as_secs() / 60,
4745                                out.1
4746                            );
4747                            stop(state, &repo, &pr, &why).await?;
4748                            return Ok(pr);
4749                        }
4750                        if verdict == Refused::Recheck {
4751                            rechecked = true;
4752                        }
4753                        state.event(
4754                            "land",
4755                            "merge refused while the branch policy is not satisfied yet; waiting",
4756                        );
4757                        state.save()?;
4758                        waited += POLL;
4759                        forge.poll().await;
4760                    }
4761                }
4762            }
4763            Step::Rebase => {
4764                // Bounded by the same budget as a fix, because a rebase that
4765                // keeps being needed means the base moves faster than this
4766                // run can land and a person should decide what to do. It
4767                // spends none of that budget: the change is not what is
4768                // wrong.
4769                if rebases >= budget {
4770                    let why = format!(
4771                        "the base moved under this branch {budget} time(s) and it still does \
4772                         not merge; rebasing again would only race it"
4773                    );
4774                    stop(state, &repo, &pr, &why).await?;
4775                    return Ok(pr);
4776                }
4777                rebases += 1;
4778                let Some(branch) = state.winner().map(|w| w.branch.clone()) else {
4779                    stop(
4780                        state,
4781                        &repo,
4782                        &pr,
4783                        "the pull request conflicts and this run has no winning branch to rebase",
4784                    )
4785                    .await?;
4786                    return Ok(pr);
4787                };
4788                let base = state.base_branch.clone();
4789                state.event(
4790                    "land",
4791                    format!("{} no longer merges; rebasing onto {base}", pr.url),
4792                );
4793                state.save()?;
4794
4795                // Onto the base as the *remote* has it: the local ref may be
4796                // behind, and rebasing onto a stale base produces a branch
4797                // that conflicts all over again.
4798                let remote = state.config.merge.remote.clone();
4799                git::fetch(&repo, &remote, &base).await.ok();
4800                if owner_stop(state, &mut pr, forge).await? {
4801                    return Ok(pr);
4802                }
4803                let onto = format!("{remote}/{base}");
4804                let rebased = match rebase_landing_worktree(state, &onto).await {
4805                    Ok(crate::rebase::Rebased::Applied) => Ok(None),
4806                    Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4807                    Err(e) => Err(e),
4808                };
4809                if owner_stop(state, &mut pr, forge).await? {
4810                    return Ok(pr);
4811                }
4812                match rebased {
4813                    Ok(None) => {
4814                        let published =
4815                            publish_landing_head(state, &seen.head, pr_url, forge).await;
4816                        if owner_stop(state, &mut pr, forge).await? {
4817                            return Ok(pr);
4818                        }
4819                        let head = published?;
4820                        crate::graph::refresh_reviewed_commits(state, &branch).await;
4821                        state.pr_pushed_head = Some(head.clone());
4822                        awaiting_head = Some(head);
4823                        rechecked = false;
4824                        state.event("land", format!("rebased {branch} onto {base}"));
4825                        state.save()?;
4826                        // The forge has to re-run its checks against the
4827                        // rebased head before anything else can be decided.
4828                        waited = Duration::ZERO;
4829                        tokio::time::sleep(POLL).await;
4830                    }
4831                    // The fixer's rounds are spent (or it could not finish):
4832                    // that is a decision for a person.
4833                    Ok(Some(conflict)) => {
4834                        let why = format!(
4835                            "{} conflicts with {base} and the rebase did not apply: {}",
4836                            pr.url,
4837                            conflict.chars().take(600).collect::<String>()
4838                        );
4839                        stop(state, &repo, &pr, &why).await?;
4840                        return Ok(pr);
4841                    }
4842                    Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
4843                    Err(e) => {
4844                        let why = format!("could not rebase {branch} onto {base}: {e:#}");
4845                        stop(state, &repo, &pr, &why).await?;
4846                        return Ok(pr);
4847                    }
4848                }
4849            }
4850            Step::GiveUp { reason } => {
4851                stop(state, &repo, &pr, &reason).await?;
4852                return Ok(pr);
4853            }
4854            Step::Fix { reason } => {
4855                round += 1;
4856                waited = Duration::ZERO;
4857                for c in &pr.review_comments {
4858                    shown.insert(c.body.clone());
4859                }
4860                state.event("land", format!("round {round}: {reason}"));
4861                state.save()?;
4862
4863                let logs = failing_logs(&repo, &seen.failing_urls).await;
4864                let was_red = pr.checks == Checks::Red;
4865                if owner_stop(state, &mut pr, forge).await? {
4866                    return Ok(pr);
4867                }
4868                let fixed = forge.fix(state, &pr, round, budget, &reason, &logs).await?;
4869                if owner_stop(state, &mut pr, forge).await? {
4870                    return Ok(pr);
4871                }
4872                match fixed {
4873                    Fixed::Committed { head } => {
4874                        // Whatever the next look shows may still be the
4875                        // previous head; see `awaiting_new_head`.
4876                        state.pr_pushed_head = Some(head.clone());
4877                        awaiting_head = Some(head);
4878                        rechecked = false;
4879                        waited = Duration::ZERO;
4880                        forge.poll().await;
4881                    }
4882                    Fixed::Declined if was_red => {
4883                        // Review comments may have required this round even
4884                        // when CI was unrelated. Once the fixer has read them,
4885                        // use the same structural attribution before charging
4886                        // a no-commit stop to the task.
4887                        if let Some(evidence) = forge.attribution(state, &seen).await
4888                            && evidence.unrelated(&seen.head, &pr.failing)
4889                        {
4890                            let fresh = forge.view(&repo, pr_url).await?;
4891                            if fresh.head != seen.head
4892                                || fresh.rollup_head != seen.rollup_head
4893                                || fresh.pr.state != PrLifecycle::Open
4894                                || fresh.pr.checks != Checks::Red
4895                                || fresh.pr.failing != pr.failing
4896                                || fresh
4897                                    .pr
4898                                    .review_comments
4899                                    .iter()
4900                                    .any(|c| !shown.contains(&c.body))
4901                            {
4902                                continue;
4903                            }
4904                            if owner_stop(state, &mut pr, forge).await? {
4905                                return Ok(pr);
4906                            }
4907                            hold_unrelated(state, &pr, evidence).await?;
4908                            return Ok(pr);
4909                        }
4910                        let why = format!(
4911                            "the fixer produced no commit while {} check(s) were failing \
4912                             ({}); stopping instead of looping on an unchanged tree",
4913                            pr.failing.len(),
4914                            pr.failing.join(", ")
4915                        );
4916                        stop(state, &repo, &pr, &why).await?;
4917                        return Ok(pr);
4918                    }
4919                    // Comment-driven round with no commit: the fixer read the
4920                    // comments and changed nothing, which is a decision it is
4921                    // allowed to make. The comments are recorded as shown, so
4922                    // the next observation sees a clean pull request.
4923                    Fixed::Declined => state.event(
4924                        "land",
4925                        format!("round {round}: fixer declined the comments, nothing committed"),
4926                    ),
4927                    Fixed::Deferred => {
4928                        // No fixer was available to judge these comments or
4929                        // checks. Keep the previous completed-round count and
4930                        // return without handing the PR off as a task failure.
4931                        state.status = RunStatus::Blocked;
4932                        state.save()?;
4933                        return Ok(pr);
4934                    }
4935                    Fixed::Failed(why) => {
4936                        stop(state, &repo, &pr, &format!("the fix round failed: {why}")).await?;
4937                        return Ok(pr);
4938                    }
4939                }
4940                state.save()?;
4941            }
4942        }
4943    }
4944}
4945
4946/// Owner decision for a CI failure independently reproduced on the base.
4947pub const CI_HOLD_NODE: &str = "land-ci-unrelated";
4948
4949/// Durable machine-hold evidence, bound to one observed PR head.
4950#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4951pub struct CiHold {
4952    /// Head that exhibited the independently reproduced failures.
4953    pub head: String,
4954    /// Typed owner question controlling resumption.
4955    pub question: String,
4956    /// Complete structured facts used to attribute the failures.
4957    pub evidence: CiEvidence,
4958}
4959
4960/// Only structured forge annotations are compared, never log prose.
4961#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4962struct CiFailure {
4963    path: String,
4964    start_line: u64,
4965    end_line: u64,
4966    title: String,
4967    message: String,
4968}
4969
4970#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4971struct CiCheckEvidence {
4972    label: String,
4973    head_check: u64,
4974    base_check: u64,
4975    app: u64,
4976    run: String,
4977    base_run: String,
4978    head_failures: Vec<CiFailure>,
4979    base_failures: Vec<CiFailure>,
4980    rerun_exhausted: bool,
4981}
4982
4983/// Pinned Git diff and forge failure annotations used for CI attribution.
4984#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
4985pub struct CiEvidence {
4986    head: String,
4987    base: String,
4988    changed: Vec<String>,
4989    checks: Vec<CiCheckEvidence>,
4990}
4991
4992impl CiEvidence {
4993    /// Every red check must have the same located failure on the base, outside
4994    /// the diff. Changes to CI/build configuration invalidate this inference.
4995    fn unrelated(&self, head: &str, failing: &[String]) -> bool {
4996        if self.head != head
4997            || self.base.is_empty()
4998            || failing.is_empty()
4999            || self.changed.iter().any(|p| {
5000                p.starts_with(".github/")
5001                    || p.starts_with(".config/")
5002                    || p.starts_with(".cargo/")
5003                    || matches!(
5004                        p.rsplit('/').next().unwrap_or(p),
5005                        "Cargo.toml"
5006                            | "Cargo.lock"
5007                            | "Makefile.toml"
5008                            | "build.rs"
5009                            | "rust-toolchain.toml"
5010                    )
5011            })
5012        {
5013            return false;
5014        }
5015        failing.iter().all(|label| {
5016            let matches: Vec<_> = self.checks.iter().filter(|c| &c.label == label).collect();
5017            matches.len() == 1
5018                && matches[0].rerun_exhausted
5019                && !matches[0].head_failures.is_empty()
5020                && matches[0].head_failures.iter().all(|f| {
5021                    // Workflow-level exit-code annotations do not identify a
5022                    // failing test or source file, and prove nothing here.
5023                    !f.path.is_empty()
5024                        && !f.path.starts_with('.')
5025                        && !f.path.contains("..")
5026                        && !f.path.starts_with('/')
5027                        && !self.changed.contains(&f.path)
5028                        && matches[0].base_failures.contains(f)
5029                })
5030        })
5031    }
5032}
5033
5034async fn ci_json(repo: &Path, endpoint: String) -> Option<serde_json::Value> {
5035    let (ok, body) = gh(repo, &["api".into(), endpoint]).await.ok()?;
5036    if !ok {
5037        return None;
5038    }
5039    serde_json::from_str(&body).ok()
5040}
5041
5042async fn ci_annotations(repo: &Path, check: &serde_json::Value) -> Option<Vec<CiFailure>> {
5043    let count = check.pointer("/output/annotations_count")?.as_u64()?;
5044    if count == 0 || count >= 100 {
5045        return None;
5046    }
5047    let id = check.get("id")?.as_u64()?;
5048    let value = ci_json(
5049        repo,
5050        format!("repos/{{owner}}/{{repo}}/check-runs/{id}/annotations?per_page=100"),
5051    )
5052    .await?;
5053    let annotations = value.as_array()?;
5054    if annotations.len() != count as usize {
5055        return None;
5056    }
5057    annotations
5058        .iter()
5059        .filter(|a| a["annotation_level"] == "failure")
5060        .map(|a| {
5061            Some(CiFailure {
5062                path: a["path"].as_str()?.into(),
5063                start_line: a["start_line"].as_u64()?,
5064                end_line: a["end_line"].as_u64()?,
5065                title: a["title"].as_str()?.into(),
5066                message: a["message"].as_str()?.into(),
5067            })
5068        })
5069        .collect()
5070}
5071
5072async fn ci_runs(repo: &Path, sha: &str) -> Option<Vec<serde_json::Value>> {
5073    let value = ci_json(
5074        repo,
5075        format!("repos/{{owner}}/{{repo}}/commits/{sha}/check-runs?per_page=100&filter=latest"),
5076    )
5077    .await?;
5078    let runs = value["check_runs"].as_array()?;
5079    if value["total_count"].as_u64()? != runs.len() as u64 || runs.len() >= 100 {
5080        return None;
5081    }
5082    Some(runs.clone())
5083}
5084
5085async fn collect_ci_evidence(state: &RunState, seen: &Seen) -> Option<CiEvidence> {
5086    let repo = &state.repo;
5087    let base_ref = format!("refs/remotes/{}/{}", state.config.merge.remote, seen.base);
5088    let base = git::git_raw(repo, &["rev-parse", "--verify", &base_ref])
5089        .await
5090        .ok()?;
5091    if !base.ok() {
5092        return None;
5093    }
5094    let range = format!("{}...{}", base.stdout, seen.head);
5095    // No rename detection: both old and new paths must count as changed.
5096    let diff = git::git_raw(
5097        repo,
5098        &["diff", "--no-renames", "--name-only", "-z", &range, "--"],
5099    )
5100    .await
5101    .ok()?;
5102    if !diff.ok() {
5103        return None;
5104    }
5105    let head_runs = ci_runs(repo, &seen.head).await?;
5106    let base_runs = ci_runs(repo, &base.stdout).await?;
5107    let mut checks = Vec::new();
5108    for label in &seen.pr.failing {
5109        let matching = |runs: &[serde_json::Value]| {
5110            runs.iter()
5111                .filter(|r| r["name"].as_str() == Some(label))
5112                .cloned()
5113                .collect::<Vec<_>>()
5114        };
5115        let heads = matching(&head_runs);
5116        let bases = matching(&base_runs);
5117        if heads.len() != 1 || bases.len() != 1 {
5118            return None;
5119        }
5120        let h = &heads[0];
5121        let b = &bases[0];
5122        if h["conclusion"] != "failure"
5123            || b["conclusion"] != "failure"
5124            || h["status"] != "completed"
5125            || b["status"] != "completed"
5126            || h["head_sha"].as_str()? != seen.head
5127            || b["head_sha"].as_str()? != base.stdout
5128            || h.pointer("/app/id")?.as_u64()? != b.pointer("/app/id")?.as_u64()?
5129        {
5130            return None;
5131        }
5132        let run = run_of(h["details_url"].as_str()?)?;
5133        let contexts: Vec<_> = seen.contexts.iter().filter(|c| &c.label == label).collect();
5134        if contexts.len() != 1
5135            || contexts[0].run.as_ref() != Some(&run)
5136            || contexts[0].verdict != Verdict::Fail
5137        {
5138            return None;
5139        }
5140        // A persisted request alone is not a completed rerun: require the
5141        // workflow's structured attempt number as well.
5142        let workflow =
5143            ci_json(repo, format!("repos/{{owner}}/{{repo}}/actions/runs/{run}")).await?;
5144        let base_run = run_of(b["details_url"].as_str()?)?;
5145        let base_workflow = ci_json(
5146            repo,
5147            format!("repos/{{owner}}/{{repo}}/actions/runs/{base_run}"),
5148        )
5149        .await?;
5150        if base_workflow["head_sha"].as_str()? != base.stdout
5151            || base_workflow["head_branch"].as_str()? != seen.base
5152            || base_workflow["status"] != "completed"
5153            || base_workflow["conclusion"] != "failure"
5154            || base_workflow["workflow_id"].as_u64()? != workflow["workflow_id"].as_u64()?
5155        {
5156            return None;
5157        }
5158        let exhausted = state
5159            .land_reruns
5160            .as_ref()
5161            .is_some_and(|r| r.head == seen.head && r.runs.contains_key(&run))
5162            && workflow["head_sha"].as_str()? == seen.head
5163            && workflow["run_attempt"].as_u64()? > 1
5164            && workflow["status"] == "completed";
5165        checks.push(CiCheckEvidence {
5166            label: label.clone(),
5167            head_check: h["id"].as_u64()?,
5168            base_check: b["id"].as_u64()?,
5169            app: h.pointer("/app/id")?.as_u64()?,
5170            run,
5171            base_run,
5172            head_failures: ci_annotations(repo, h).await?,
5173            base_failures: ci_annotations(repo, b).await?,
5174            rerun_exhausted: exhausted,
5175        });
5176    }
5177    Some(CiEvidence {
5178        head: seen.head.clone(),
5179        base: base.stdout,
5180        changed: diff
5181            .stdout
5182            .split('\0')
5183            .filter(|p| !p.is_empty())
5184            .map(str::to_owned)
5185            .collect(),
5186        checks,
5187    })
5188}
5189
5190async fn hold_unrelated(state: &mut RunState, pr: &PrState, evidence: CiEvidence) -> Result<()> {
5191    let detail = serde_json::to_string(&evidence)?;
5192    let resume = "daemon: CI を確認して同じ run を再開する".to_owned();
5193    let hold = "operator: 保留して後で判断する".to_owned();
5194    let mut q = ask::Question::new(
5195        state.id.clone(),
5196        CI_HOLD_NODE.into(),
5197        "land".into(),
5198        "base でも再現する CI の失敗を検出したため、試行を消費せず保留しました".into(),
5199        format!(
5200            "必須チェックや承認は省略しません。再開時にも同じ workflow run は自動再実行しません。\n構造化された判定根拠:\n{detail}"
5201        ),
5202        vec![resume.clone(), hold.clone()],
5203    );
5204    q.actions.insert(
5205        resume,
5206        ask::ChoiceAction::Resume {
5207            run: state.id.clone(),
5208        },
5209    );
5210    q.actions.insert(hold, ask::ChoiceAction::Hold);
5211    ask::Questions::open().put_executable(&mut q)?;
5212    state.land_unrelated = Some(CiHold {
5213        head: evidence.head.clone(),
5214        question: q.id.clone(),
5215        evidence,
5216    });
5217    state.event("land", format!("[ci-unrelated] base reproduces every located failure after rerun; owner question {}: {detail}", q.id));
5218    state.end_pass_parked(crate::run::ParkCause::Question);
5219    state.parked = true;
5220    state.save()?;
5221    if let Err(e) = ask::notify(&state.config.notify, &q).await {
5222        tracing::warn!("could not notify about CI hold: {e:#}");
5223    }
5224    // A durable question and evidence are sufficient even if commenting fails.
5225    let body = stop_comment(
5226        &state.id,
5227        "CI failures independently reproduced on the base; awaiting an owner decision without charging the task attempt",
5228    );
5229    let (_, body) = crate::github_text::prepare(state, "", &body);
5230    gh(
5231        &state.repo,
5232        &[
5233            "pr".into(),
5234            "comment".into(),
5235            pr.number.to_string(),
5236            "--body".into(),
5237            body,
5238        ],
5239    )
5240    .await
5241    .ok();
5242    Ok(())
5243}
5244
5245/// One observation, plus the two things [`PrState`] deliberately does not carry:
5246/// the title (needed for the squash subject) and where the failing checks'
5247/// logs live.
5248#[derive(Clone)]
5249struct Seen {
5250    pr: PrState,
5251    title: String,
5252    failing_urls: Vec<(String, String)>,
5253    /// `headRefOid`: the commit this observation, checks included, is about.
5254    head: String,
5255    /// The commit the checks belong to, read from the same GraphQL node as
5256    /// the checks themselves. Empty when unreadable.
5257    rollup_head: String,
5258    /// `mergeStateStatus` as the forge spelled it. [`Blocking`] folds BLOCKED,
5259    /// BEHIND and DRAFT together, and a stop reason has to say which.
5260    merge_state: String,
5261    /// Every check of the rollup, for naming what an armed merge waits on.
5262    contexts: Vec<CheckInfo>,
5263    /// `baseRefName`, to look up which contexts the base requires.
5264    base: String,
5265}
5266
5267/// One check of the rollup as far as a stop reason needs it.
5268#[derive(Clone)]
5269struct CheckInfo {
5270    label: String,
5271    verdict: Verdict,
5272    required: Option<bool>,
5273    /// The workflow run the check belongs to; `None` for an external status.
5274    run: Option<String>,
5275}
5276
5277/// What to do with a red head before a fix round is spent on it.
5278#[derive(Debug, Clone, PartialEq, Eq)]
5279enum RerunStep {
5280    /// Rerun these workflow runs (each not yet rerun for this head).
5281    Rerun(Vec<String>),
5282    /// Reran already and the forge may still report the old failure: wait.
5283    Grace,
5284    /// Nothing to rerun, or the rerun did not help: the fixer's turn.
5285    Fix,
5286}
5287
5288/// Failed checks a rerun may help: a failure of a check that blocks (`required`
5289/// unreadable counts), as `(label, run)`. Judged one workflow run at a time;
5290/// pending checks in another run do not delay this run's rerun.
5291#[cfg(test)]
5292fn rerunnable(contexts: &[CheckInfo]) -> Vec<(String, Option<String>)> {
5293    rerunnable_with(contexts, false)
5294}
5295
5296fn rerunnable_with(
5297    contexts: &[CheckInfo],
5298    include_optional: bool,
5299) -> Vec<(String, Option<String>)> {
5300    contexts
5301        .iter()
5302        .filter(|c| c.verdict == Verdict::Fail && (include_optional || c.required != Some(false)))
5303        // `gh run rerun --failed` refuses a run with a job still going.
5304        .filter(|c| {
5305            c.run.as_ref().is_none_or(|r| {
5306                !contexts.iter().any(|o| {
5307                    matches!(o.verdict, Verdict::Pending | Verdict::Unknown)
5308                        && o.run.as_ref() == Some(r)
5309                })
5310            })
5311        })
5312        .map(|c| (c.label.clone(), c.run.clone()))
5313        .collect()
5314}
5315
5316/// Pure: rerun once per run id, wait out the grace, then hand over.
5317fn rerun_step(
5318    failing: &[(String, Option<String>)],
5319    record: Option<&LandReruns>,
5320    head: &str,
5321    now: i64,
5322) -> RerunStep {
5323    // A failure with no workflow run (an external status) cannot be rerun, and
5324    // the fixer's push re-runs everything anyway.
5325    if failing.is_empty() || failing.iter().any(|(_, run)| run.is_none()) {
5326        return RerunStep::Fix;
5327    }
5328    // Reruns belong to the head they were made on.
5329    let record = record.filter(|r| r.head.eq_ignore_ascii_case(head));
5330    let mut fresh: Vec<String> = Vec::new();
5331    let mut recent = false;
5332    for run in failing.iter().filter_map(|(_, run)| run.as_deref()) {
5333        match record.and_then(|r| r.runs.get(run)) {
5334            None => {
5335                if !fresh.iter().any(|f| f == run) {
5336                    fresh.push(run.to_owned());
5337                }
5338            }
5339            Some(&at) if now - at < crate::release_watch::RERUN_GRACE => recent = true,
5340            Some(_) => {}
5341        }
5342    }
5343    if !fresh.is_empty() {
5344        RerunStep::Rerun(fresh)
5345    } else if recent {
5346        RerunStep::Grace
5347    } else {
5348        RerunStep::Fix
5349    }
5350}
5351
5352/// Read the pull request: `gh pr view` for the top-level thread and merge
5353/// state, `gh api graphql` for the last commit and its checks, `gh api` for the
5354/// inline review comments `gh pr view` does not report.
5355async fn observe(repo: &Path, pr_url: &str) -> Result<Seen> {
5356    let view = gh(
5357        repo,
5358        &[
5359            "pr".to_owned(),
5360            "view".to_owned(),
5361            pr_url.to_owned(),
5362            "--json".to_owned(),
5363            "url,number,state,title,reviews,comments,mergeStateStatus,headRefOid,baseRefName"
5364                .to_owned(),
5365        ],
5366    )
5367    .await?;
5368    if !view.0 {
5369        bail!("gh pr view {pr_url}: {}", view.1);
5370    }
5371    let number = parse_pr(&view.1)?.number;
5372    let node = last_commit_node(repo, number).await;
5373    let mut seen = seen_from(&view.1, node.as_deref())?;
5374
5375    let inline = gh(
5376        repo,
5377        &[
5378            "api".to_owned(),
5379            format!("repos/{{owner}}/{{repo}}/pulls/{}/comments", seen.pr.number),
5380        ],
5381    )
5382    .await?;
5383    if inline.0 {
5384        match parse_inline_comments(&inline.1) {
5385            Ok(mut comments) => seen.pr.review_comments.append(&mut comments),
5386            // An unreadable inline thread must not end a landing: the rollup
5387            // and the top-level thread are still real signal.
5388            Err(e) => tracing::warn!("inline review comments unreadable: {e}"),
5389        }
5390    } else {
5391        tracing::warn!("gh api pulls/{}/comments: {}", seen.pr.number, inline.1);
5392    }
5393    Ok(seen)
5394}
5395
5396/// Build a [`Seen`] from the `gh pr view` json and the last-commit node
5397/// response. No I/O.
5398///
5399/// The commit oid and the checks are read from the *same* node, so the rollup
5400/// is bound to the commit it belongs to by construction; two reads that agree
5401/// before and after another response prove nothing about what that response
5402/// held. The view's own rollup is never used. A node that is missing,
5403/// unreadable, carries GraphQL errors, or whose checks run past the page
5404/// leaves `rollup_head` empty and the checks `Unknown`, which the loop treats
5405/// as "look again" and never as a reason to merge.
5406fn seen_from(view_json: &str, node_json: Option<&str>) -> Result<Seen> {
5407    let mut pr = parse_pr(view_json)?;
5408    let raw: GhPr = serde_json::from_str(view_json).context("re-read pull request json")?;
5409
5410    let mut rollup_head = String::new();
5411    let mut failing_urls = Vec::new();
5412    let mut contexts = Vec::new();
5413    let mut checks = Checks::Unknown;
5414    let mut failing = Vec::new();
5415    if let Some((oid, rollup)) = node_json.and_then(parse_last_commit_node) {
5416        (checks, failing) = rollup_verdict(&rollup);
5417        failing_urls = rollup
5418            .iter()
5419            .filter(|c| c.verdict() == Verdict::Fail)
5420            .filter_map(|c| c.url().map(|u| (c.label(), u.to_owned())))
5421            .collect();
5422        contexts = rollup
5423            .iter()
5424            .map(|c| CheckInfo {
5425                label: c.label(),
5426                verdict: c.verdict(),
5427                required: c.is_required,
5428                run: c.url().and_then(run_of),
5429            })
5430            .collect();
5431        rollup_head = oid;
5432    }
5433    pr.checks = checks;
5434    pr.failing = failing;
5435
5436    Ok(Seen {
5437        pr,
5438        title: raw.title,
5439        failing_urls,
5440        head: raw.head_ref_oid,
5441        rollup_head,
5442        merge_state: raw.merge_state_status,
5443        contexts,
5444        base: raw.base_ref_name,
5445    })
5446}
5447
5448/// The last commit's oid and its checks from one GraphQL response, `None`
5449/// when anything about it cannot be trusted.
5450fn parse_last_commit_node(json: &str) -> Option<(String, Vec<GhCheck>)> {
5451    let v: serde_json::Value = serde_json::from_str(json).ok()?;
5452    if v.get("errors").is_some_and(|e| !e.is_null()) {
5453        return None;
5454    }
5455    let commit = v.pointer("/data/repository/pullRequest/commits/nodes/0/commit")?;
5456    let oid = commit.get("oid")?.as_str().filter(|o| !o.is_empty())?;
5457    let contexts = commit.pointer("/statusCheckRollup/contexts");
5458    let Some(contexts) = contexts.filter(|c| !c.is_null()) else {
5459        // No rollup at all: the commit has no checks.
5460        return Some((oid.to_owned(), Vec::new()));
5461    };
5462    if contexts.pointer("/pageInfo/hasNextPage")?.as_bool()? {
5463        return None;
5464    }
5465    let nodes = contexts.get("nodes")?.as_array()?;
5466    let rollup = nodes
5467        .iter()
5468        .map(|n| serde_json::from_value::<GhCheck>(n.clone()))
5469        .collect::<Result<Vec<_>, _>>()
5470        .ok()?;
5471    Some((oid.to_owned(), rollup))
5472}
5473
5474/// The pull request's last commit and its checks, in one response. `None`
5475/// when the forge could not be asked.
5476async fn last_commit_node(repo: &Path, number: u64) -> Option<String> {
5477    let out = gh(
5478        repo,
5479        &[
5480            "api".to_owned(),
5481            "graphql".to_owned(),
5482            "-F".to_owned(),
5483            "owner={owner}".to_owned(),
5484            "-F".to_owned(),
5485            "repo={repo}".to_owned(),
5486            "-F".to_owned(),
5487            format!("number={number}"),
5488            "-f".to_owned(),
5489            "query=query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,\
5490             name:$repo){pullRequest(number:$number){commits(last:1){nodes{commit{oid \
5491             statusCheckRollup{contexts(first:100){pageInfo{hasNextPage} nodes{\
5492             ... on CheckRun{name status conclusion detailsUrl \
5493             isRequired(pullRequestNumber:$number)} \
5494             ... on StatusContext{context state targetUrl \
5495             isRequired(pullRequestNumber:$number)}}}}}}}}}}"
5496                .to_owned(),
5497        ],
5498    )
5499    .await
5500    .ok()?;
5501    out.0.then_some(out.1)
5502}
5503
5504/// What a fix round did.
5505#[doc(hidden)]
5506#[derive(Debug, PartialEq)]
5507pub enum Fixed {
5508    /// The fixer committed something, and this is the head that was pushed.
5509    Committed {
5510        /// The commit now at the tip of the pushed branch.
5511        head: String,
5512    },
5513    /// The fixer ran and chose to change nothing.
5514    Declined,
5515    /// Machine availability prevented a result; retry this round on resume.
5516    Deferred,
5517    /// The fixer could not run, or said nothing usable.
5518    Failed(String),
5519}
5520
5521/// Hand the failures and the comments to the fixer, then commit and push.
5522///
5523/// The fixer works in the winner's own worktree so its commits land on the
5524/// branch the pull request is built from, and it runs with `allow_write` for
5525/// the same reason.
5526#[doc(hidden)]
5527pub async fn fix_round(
5528    state: &mut RunState,
5529    pr: &PrState,
5530    round: usize,
5531    budget: usize,
5532    reason: &str,
5533    logs: &str,
5534) -> Result<Fixed> {
5535    let winner = state
5536        .winner()
5537        .cloned()
5538        .context("landing needs a winning candidate; none is recorded on this run")?;
5539    let roles = state
5540        .config
5541        .resolve_roles()
5542        .context("resolve the roster for the fix round")?;
5543    // Same chain as the review loop (see `crate::fixer`): the configured
5544    // fixers in order, otherwise the winner's own author continuing its own
5545    // conversation - the competition is over, so its context is pure benefit.
5546    let attempts = crate::fixer::attempts(state, &roles, &winner);
5547    let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
5548
5549    let prompt = fix_prompt(state, pr, round, budget, reason, logs);
5550    let mut artifacts = agent::artifacts_dir(&state.dir());
5551    if let Some(request) = state
5552        .merge_repairs
5553        .last()
5554        .filter(|r| r.phase == crate::run::MergeRepairPhase::Fixing)
5555    {
5556        artifacts = artifacts.join(format!("merge-repair-{}", request.question));
5557    }
5558    let prompt = if state.config.cache_dir().is_some() {
5559        format!("{prompt}\n\n{}", prompt::build_cache_note("fix", true))
5560    } else {
5561        prompt
5562    };
5563    // Read before the fixer runs: it normally commits for itself, so HEAD has
5564    // already moved by the time it returns and a later read would see no
5565    // progress (run 20261004-041622-5769 stopped on a fix that had landed).
5566    let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5567    // Each id at most once, forward only: the next one is asked only when the
5568    // call advances, and only the last attempt's result is judged below, so an
5569    // exhausted chain ends as a single failed fixer does.
5570    let mut last = None;
5571    for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
5572        if owner_requested_stop(state)? {
5573            return Ok(Fixed::Declined);
5574        }
5575        let mut seat = seat_of(state, &seat_key, &spec.id);
5576        let stem = if i == 0 {
5577            format!("land-{round}")
5578        } else {
5579            format!("land-{round}-{}", spec.id)
5580        };
5581        let out = agent::invoke(
5582            &spec,
5583            &mut seat,
5584            &Invocation {
5585                coordination: state.config.coordination.as_ref(),
5586                fetched_base: state.fetched_base.as_ref(),
5587                cwd: &winner.worktree,
5588                prompt: &prompt,
5589                timeout: Duration::from_secs(state.config.graph.timeout_fix),
5590                allow_write: true,
5591                sessions: state.config.graph.sessions,
5592                artifacts: &artifacts,
5593                stem: &stem,
5594                run: &state.id,
5595                node: "land",
5596                cache_dir: state.config.cache_dir().as_deref(),
5597                attachments: &[],
5598                writable: &[],
5599            },
5600        )
5601        .await;
5602        state.seats.insert(seat.key.clone(), seat);
5603        if let Some(next) = ids.get(i + 1)
5604            && agent::chain_advances(&out)
5605        {
5606            let (class, why) = crate::fixer::failure_of(&out);
5607            crate::graph::record_handover(state, "land", &seat_key, &spec.id, next, &class, &why);
5608            continue;
5609        }
5610        last = Some((seat_key, out));
5611        break;
5612    }
5613    let (seat_key, out) = last.expect("the fixer chain always has an entry");
5614
5615    match out {
5616        Ok(o) if o.quota_exhausted() => {
5617            let loss = crate::run::QuotaLoss::from_report(seat_key, "land", o.quota.as_ref());
5618            crate::graph::block_machine_outage(state, "land", std::slice::from_ref(&loss));
5619            state.quota.push(loss);
5620            state.status = RunStatus::Blocked;
5621            state.save()?;
5622            // Keep partial edits reachable on the winner branch and leave a
5623            // clean tree for resume_landing_head. The resumed landing gates
5624            // this new commit before it can publish it; no push happens here.
5625            let inherited = crate::repo_lock::WaitPolicy::current();
5626            let wait = crate::repo_lock::WaitPolicy::new(
5627                Duration::from_secs(state.config.graph.timeout_resource_wait),
5628                move || inherited.cancelled(),
5629            );
5630            let _coordination = crate::repo_lock::SeatLease::operation_with(
5631                &winner.worktree,
5632                state.config.coordination.as_ref(),
5633                &wait,
5634            )
5635            .await?;
5636            let rescue = git::rescue_commit(
5637                &winner.worktree,
5638                &format!("magi: deferred land round {round} (uncommitted work)"),
5639            )
5640            .await?;
5641            state.note_withheld("land", &rescue.withheld);
5642            state.save()?;
5643            return Ok(Fixed::Deferred);
5644        }
5645        Ok(o) if !o.usable() => {
5646            return Ok(Fixed::Failed(format!(
5647                "the fixer produced nothing usable ({})",
5648                crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
5649            )));
5650        }
5651        Ok(_) => {}
5652        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => return Err(e),
5653        Err(e) => return Ok(Fixed::Failed(format!("{e:#}"))),
5654    }
5655
5656    if owner_requested_stop(state)? {
5657        return Ok(Fixed::Declined);
5658    }
5659
5660    // An agent that edited files but never committed would otherwise push
5661    // nothing and look like a refusal.
5662    let wait = crate::repo_lock::WaitPolicy::new(
5663        Duration::from_secs(state.config.graph.timeout_resource_wait),
5664        || false,
5665    );
5666    let coordination = crate::repo_lock::SeatLease::operation_with(
5667        &winner.worktree,
5668        state.config.coordination.as_ref(),
5669        &wait,
5670    )
5671    .await?;
5672    match git::rescue_commit(
5673        &winner.worktree,
5674        &format!("magi: land round {round} fixes (uncommitted work)"),
5675    )
5676    .await
5677    {
5678        Ok(r) => state.note_withheld("land", &r.withheld),
5679        Err(e)
5680            if state
5681                .merge_repairs
5682                .last()
5683                .is_some_and(|r| r.phase == crate::run::MergeRepairPhase::Fixing) =>
5684        {
5685            return Ok(Fixed::Failed(format!("saving repair commit failed: {e:#}")));
5686        }
5687        Err(_) => {}
5688    }
5689    let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5690    drop(coordination);
5691    if after == before {
5692        return Ok(Fixed::Declined);
5693    }
5694
5695    if owner_requested_stop(state)? {
5696        return Ok(Fixed::Declined);
5697    }
5698    crate::pr_head::ensure_owner(state)?;
5699    let remote = state.config.merge.remote.clone();
5700    anyhow::ensure!(
5701        crate::foreign_checkout::write_back(state, &after).await?,
5702        "foreign checkout delivery is blocked; see the run events"
5703    );
5704    let public_head = state.pr_branch().unwrap_or(&winner.branch).to_owned();
5705    let push = if state.pr_head.is_some() {
5706        let expected = state
5707            .pr_pushed_head
5708            .as_deref()
5709            .context("missing public head lease")?;
5710        git::push_as(
5711            &winner.worktree,
5712            &remote,
5713            &winner.branch,
5714            &public_head,
5715            expected,
5716        )
5717        .await?
5718    } else {
5719        git::push(&winner.worktree, &remote, &winner.branch).await?
5720    };
5721    if !push.ok() {
5722        return Ok(Fixed::Failed(format!(
5723            "pushing {} to {remote} failed: {}",
5724            public_head, push.stderr
5725        )));
5726    }
5727    state.event(
5728        "land",
5729        format!("round {round}: pushed a fix to {public_head}"),
5730    );
5731    state.pr_pushed_head = Some(after.clone());
5732    state.save()?;
5733    Ok(Fixed::Committed { head: after })
5734}
5735
5736/// Fetch or create a seat, keeping its conversation across nodes.
5737pub(crate) fn seat_of(state: &mut RunState, key: &str, agent: &str) -> SeatState {
5738    crate::fixer::seat_for(state, key, agent)
5739}
5740
5741/// Recheck the live PR and local worktree before spending the requested fix.
5742/// A moved/closed PR is held, never repaired using obsolete evidence.
5743pub(crate) async fn repair_preflight(state: &RunState, url: &str, head: &str) -> Result<PrState> {
5744    let seen = observe(&state.repo, url).await?;
5745    let winner = state.winner().context("no winning branch to repair")?;
5746    if seen.pr.state != PrLifecycle::Open || seen.head != head {
5747        anyhow::bail!(
5748            "PR is closed or its head changed; review the current head before requesting repair again"
5749        );
5750    }
5751    if git::rev_parse(&winner.worktree, "HEAD").await? != head
5752        || !git::is_clean(&winner.worktree).await?
5753    {
5754        anyhow::bail!(
5755            "repair worktree is missing, dirty or at a different head; restore the reviewed branch and resume this run"
5756        );
5757    }
5758    // Always disable any forge-side reservation, including one not recorded
5759    // by this run. A repair request is never merge authorization.
5760    let view = gh(
5761        &state.repo,
5762        &[
5763            "pr".into(),
5764            "view".into(),
5765            url.into(),
5766            "--json".into(),
5767            "autoMergeRequest,headRefOid,state,headRefName,baseRefName,isCrossRepository".into(),
5768        ],
5769    )
5770    .await?;
5771    if !view.0 {
5772        bail!("cannot check auto-merge: {}", view.1);
5773    }
5774    let live: serde_json::Value = serde_json::from_str(&view.1)?;
5775    let public = state.pr_branch().unwrap_or(&winner.branch);
5776    if live["state"] != "OPEN"
5777        || live["headRefOid"] != head
5778        || live["headRefName"] != public
5779        || live["baseRefName"] != state.base_branch
5780        || live["isCrossRepository"] != false
5781    {
5782        bail!("PR changed during repair preflight; review its current head explicitly");
5783    }
5784    let auto_merge = live
5785        .get("autoMergeRequest")
5786        .context("cannot read the PR auto-merge reservation; repair held")?;
5787    if !auto_merge.is_null() {
5788        let out = gh(&state.repo, &disable_automerge_argv(seen.pr.number)).await?;
5789        if !out.0 {
5790            bail!("cannot disable auto-merge; repair held: {}", out.1);
5791        }
5792    }
5793    Ok(seen.pr)
5794}
5795
5796/// What the fixer is told.
5797fn fix_prompt(
5798    state: &RunState,
5799    pr: &PrState,
5800    round: usize,
5801    budget: usize,
5802    reason: &str,
5803    logs: &str,
5804) -> String {
5805    let mut s = format!(
5806        "Your patch is open as a pull request and it is not landing. Land round \
5807         {round} of {budget}.\n\n\
5808         Pull request: {}\n\n\
5809         What is holding it: {reason}\n\n\
5810         # The task\n\n{}\n",
5811        pr.url, state.instruction
5812    );
5813
5814    if pr.failing.is_empty() {
5815        s.push_str("\n# Failing checks\n\n(none)\n");
5816    } else {
5817        let _ = write!(s, "\n# Failing checks\n\n- {}\n", pr.failing.join("\n- "));
5818        if logs.trim().is_empty() {
5819            s.push_str(
5820                "\nNo log could be read; investigate the failure with necessary focused checks.\n",
5821            );
5822        } else {
5823            let _ = write!(s, "\n## Failing log tails\n\n{logs}\n");
5824        }
5825    }
5826
5827    if pr.review_comments.is_empty() {
5828        s.push_str("\n# Review comments\n\n(none)\n");
5829    } else {
5830        s.push_str("\n# Review comments\n");
5831        for c in &pr.review_comments {
5832            let where_ = match (&c.path, c.line) {
5833                (Some(p), Some(l)) => format!(" ({p}:{l})"),
5834                (Some(p), None) => format!(" ({p})"),
5835                _ => String::new(),
5836            };
5837            let _ = write!(s, "\n## {}{where_}\n\n{}\n", c.author, c.body.trim());
5838        }
5839    }
5840
5841    s.push_str(
5842        "\n# Rules\n\n\
5843         1. Fix the cause, never the symptom. Do not delete, skip, or weaken a \
5844            failing test; do not silence a lint with an allow attribute; do not \
5845            stretch a timeout to hide a race. If the check is right, the code is \
5846            wrong.\n\
5847         2. Change nothing the checks and the comments did not raise. A \
5848            drive-by refactor turns a one-line fix into a pull request that \
5849            needs reviewing again.\n\
5850         3. If a comment is wrong, say so with a checkable argument and change \
5851            nothing for it. A declined comment with a reason is a correct \
5852            outcome; a change made to appease a reviewer is not.\n\
5853         4. Commit in this worktree. magi pushes to the pull request's branch \
5854            for you; do not push, merge, or close anything yourself.\n\
5855         5. Never name yourself, your vendor, or your model, anywhere.\n\n\
5856         # Output\n\n\
5857         Say what you changed and why, and what you declined and why.",
5858    );
5859
5860    let language = &state.config.graph.language;
5861    if !(language.trim().is_empty() || language.eq_ignore_ascii_case("en")) {
5862        let _ = write!(s, "\n\nWrite all prose in {language}.");
5863    }
5864    // After the language line, so the exception is the last word on it.
5865    s.push_str(&crate::prompt::github_rule(
5866        language,
5867        &state.config.graph.github_language,
5868    ));
5869    if let Some(overlay) = state.config.prompts.overlay("fix") {
5870        let _ = write!(s, "\n\n{overlay}");
5871    }
5872    s.push('\n');
5873    s.push_str(crate::prompt::child_run_ownership());
5874    s
5875}
5876
5877/// Failing log tails, the way the operator collects them by hand:
5878/// `gh run view --log-failed`.
5879async fn failing_logs(repo: &Path, failing: &[(String, String)]) -> String {
5880    let mut out = String::new();
5881    for (name, url) in failing.iter().take(MAX_LOGS) {
5882        let args = match (job_of(url), run_of(url)) {
5883            (Some(job), _) => vec![
5884                "run".to_owned(),
5885                "view".to_owned(),
5886                "--log-failed".to_owned(),
5887                "--job".to_owned(),
5888                job,
5889            ],
5890            (None, Some(run)) => vec![
5891                "run".to_owned(),
5892                "view".to_owned(),
5893                run,
5894                "--log-failed".to_owned(),
5895            ],
5896            // Not a GitHub Actions check - an external status has no log here.
5897            (None, None) => continue,
5898        };
5899        let (ok, body) = match gh(repo, &args).await {
5900            Ok(v) => v,
5901            Err(e) => (false, format!("{e:#}")),
5902        };
5903        if !ok && body.trim().is_empty() {
5904            continue;
5905        }
5906        let _ = write!(out, "### {name}\n\n```\n{}\n```\n\n", tail(&body, LOG_TAIL));
5907    }
5908    out
5909}
5910
5911/// Job id out of a check's `detailsUrl`
5912/// (`https://github.com/o/r/actions/runs/<run>/job/<job>`).
5913fn job_of(details_url: &str) -> Option<String> {
5914    let after = details_url.split("/job/").nth(1)?;
5915    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5916    (!id.is_empty()).then_some(id)
5917}
5918
5919/// Workflow run id out of a check's `detailsUrl`.
5920pub(crate) fn run_of(details_url: &str) -> Option<String> {
5921    let after = details_url.split("/actions/runs/").nth(1)?;
5922    let id: String = after.chars().take_while(char::is_ascii_digit).collect();
5923    (!id.is_empty()).then_some(id)
5924}
5925
5926/// The comment `stop` posts. Fixed English, whatever `[graph] language` says:
5927/// it lands on GitHub, not in front of the operator. Pure so a test can hold
5928/// it to that.
5929fn stop_comment(run_id: &str, why: &str) -> String {
5930    format!(
5931        "{MARKER}\nmagi stopped landing this pull request: {why}\n\n\
5932         The branch is untouched and the run is `{run_id}`. Nothing was merged."
5933    )
5934}
5935
5936/// Leave the pull request open, say why on it, and mark the run blocked.
5937///
5938/// The comment is what makes an unattended stop actionable: the operator wakes
5939/// up to a pull request that explains itself rather than to a silent queue.
5940async fn stop(state: &mut RunState, repo: &Path, pr: &PrState, why: &str) -> Result<()> {
5941    let body = stop_comment(&state.id, why);
5942    let (_, mut body) = crate::github_text::prepare(state, "", &body);
5943    if !body.contains(MARKER) {
5944        body = format!("{MARKER}\n{body}");
5945    }
5946    let posted = gh(
5947        repo,
5948        &[
5949            "pr".to_owned(),
5950            "comment".to_owned(),
5951            pr.number.to_string(),
5952            "--body".to_owned(),
5953            body,
5954        ],
5955    )
5956    .await;
5957    match posted {
5958        Ok((true, _)) => {}
5959        Ok((false, out)) => tracing::warn!("could not comment on {}: {out}", pr.url),
5960        Err(e) => tracing::warn!("could not comment on {}: {e:#}", pr.url),
5961    }
5962    state.status = RunStatus::Blocked;
5963    state.merge = Some(MergeOutcome {
5964        mode: MergeMode::Pr,
5965        ok: false,
5966        detail: why.to_owned(),
5967        empty: false,
5968    });
5969    state.event("land", format!("stopped: {why}"));
5970    state.save()?;
5971    Ok(())
5972}
5973
5974/// Run `gh` in `repo`, returning success and the combined output.
5975///
5976/// Combined because `gh` reports a refused merge on stderr and the pull request
5977/// json on stdout, and both are evidence.
5978///
5979/// `GH_REPO` is stripped from the child's environment: every call site here
5980/// passes an explicit `cwd` (or a full pull request URL) meaning to operate
5981/// on *that* checkout's own remote, and `gh` prefers `GH_REPO` over the
5982/// checkout it is sitting in when no `--repo` flag is given. Left unset, a
5983/// `GH_REPO` the operator happens to have exported for an unrelated script
5984/// would silently redirect [`repo_slug`] (and every other cwd-scoped call
5985/// below) to a different repository than the one actually on disk - which
5986/// for the same-repo guard in [`correct_manual_merge`] would mean the check
5987/// could be made to agree with whatever repository a forged `--merged` URL
5988/// claims, defeating it entirely.
5989pub(crate) async fn gh(cwd: &Path, args: &[String]) -> Result<(bool, String)> {
5990    let out = tokio::process::Command::new("gh")
5991        .args(args)
5992        .current_dir(cwd)
5993        .env_remove("GH_REPO")
5994        .quiet()
5995        .stdin(std::process::Stdio::null())
5996        .output()
5997        .await
5998        .with_context(|| format!("spawn gh {}", args.join(" ")))?;
5999    let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
6000    let err = String::from_utf8_lossy(&out.stderr);
6001    if body.trim().is_empty() {
6002        body = err.into_owned();
6003    } else if !err.trim().is_empty() {
6004        body.push_str(&err);
6005    }
6006    Ok((out.status.success(), body.trim().to_owned()))
6007}
6008
6009/// Verdict of one entry in the status rollup.
6010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6011pub(crate) enum Verdict {
6012    Pass,
6013    Fail,
6014    Pending,
6015    Unknown,
6016}
6017
6018impl Verdict {
6019    /// Short operator-facing check result.
6020    pub(crate) fn label(self) -> &'static str {
6021        match self {
6022            Self::Pass => "passed",
6023            Self::Fail => "failed",
6024            Self::Pending => "pending",
6025            Self::Unknown => "unknown",
6026        }
6027    }
6028}
6029
6030#[derive(Debug, Deserialize)]
6031#[serde(rename_all = "camelCase")]
6032struct GhPr {
6033    #[serde(default)]
6034    url: String,
6035    #[serde(default)]
6036    number: u64,
6037    #[serde(default)]
6038    state: String,
6039    #[serde(default)]
6040    title: String,
6041    #[serde(default)]
6042    status_check_rollup: Vec<GhCheck>,
6043    /// GitHub's own verdict on whether the pull request can be merged.
6044    ///
6045    /// Worth asking for because it is the only place the *required* check set
6046    /// is applied: the rollup lists every check equally, so a repository that
6047    /// deliberately does not require `coverage` still looks red here. See
6048    /// [`Blocking`].
6049    #[serde(default)]
6050    merge_state_status: String,
6051    /// The commit the pull request currently points at. Compared with the
6052    /// commit a fix round pushed, it is how the loop knows the forge has moved
6053    /// on and the rollup belongs to the new head.
6054    #[serde(default)]
6055    head_ref_oid: String,
6056    #[serde(default)]
6057    base_ref_name: String,
6058    #[serde(default)]
6059    reviews: Vec<GhReview>,
6060    #[serde(default)]
6061    comments: Vec<GhComment>,
6062}
6063
6064/// One rollup entry. `gh` mixes two GraphQL types in this array: a `CheckRun`
6065/// has `name`/`status`/`conclusion`, while a `StatusContext` - the old commit
6066/// status API, which is how CodeRabbit reports - has `context`/`state` and no
6067/// conclusion at all.
6068#[derive(Debug, Deserialize)]
6069#[serde(rename_all = "camelCase")]
6070struct GhCheck {
6071    #[serde(default)]
6072    name: Option<String>,
6073    #[serde(default)]
6074    context: Option<String>,
6075    #[serde(default)]
6076    status: Option<String>,
6077    #[serde(default)]
6078    conclusion: Option<String>,
6079    #[serde(default)]
6080    state: Option<String>,
6081    #[serde(default)]
6082    details_url: Option<String>,
6083    #[serde(default)]
6084    target_url: Option<String>,
6085    /// Whether the base branch requires this check. Only the GraphQL node
6086    /// carries it; `None` is "not read", never "not required".
6087    #[serde(default)]
6088    is_required: Option<bool>,
6089}
6090
6091impl GhCheck {
6092    /// Name to show a human and hand to the fixer.
6093    fn label(&self) -> String {
6094        self.name
6095            .clone()
6096            .or_else(|| self.context.clone())
6097            .unwrap_or_else(|| "(unnamed check)".to_owned())
6098    }
6099
6100    /// Where this check's logs live, when it has any.
6101    fn url(&self) -> Option<&str> {
6102        self.details_url
6103            .as_deref()
6104            .or(self.target_url.as_deref())
6105            .filter(|u| !u.is_empty())
6106    }
6107
6108    /// Did it pass?
6109    ///
6110    /// `SKIPPED` and `NEUTRAL` count as passed: the Claude review workflow
6111    /// skips release and bot pull requests by design, and a skip that blocked
6112    /// landing would block exactly the pull requests that need no review.
6113    /// `CANCELLED` counts as failed - a cancelled check did not pass, and
6114    /// merging over one is merging over a check that never ran.
6115    fn verdict(&self) -> Verdict {
6116        if let Some(status) = self.status.as_deref() {
6117            if !status.eq_ignore_ascii_case("COMPLETED") {
6118                return Verdict::Pending;
6119            }
6120        }
6121        let outcome = self
6122            .conclusion
6123            .as_deref()
6124            .or(self.state.as_deref())
6125            .unwrap_or("");
6126        match outcome.to_ascii_uppercase().as_str() {
6127            "SUCCESS" | "SKIPPED" | "NEUTRAL" => Verdict::Pass,
6128            "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE"
6129            | "ACTION_REQUIRED" => Verdict::Fail,
6130            "PENDING" | "EXPECTED" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "REQUESTED" => {
6131                Verdict::Pending
6132            }
6133            _ => Verdict::Unknown,
6134        }
6135    }
6136}
6137
6138#[derive(Debug, Deserialize)]
6139struct GhAuthor {
6140    #[serde(default)]
6141    login: String,
6142}
6143
6144#[derive(Debug, Deserialize)]
6145struct GhReview {
6146    #[serde(default)]
6147    author: GhAuthor,
6148    #[serde(default)]
6149    body: String,
6150}
6151
6152#[derive(Debug, Deserialize)]
6153struct GhComment {
6154    #[serde(default)]
6155    author: GhAuthor,
6156    #[serde(default)]
6157    body: String,
6158}
6159
6160#[derive(Debug, Deserialize)]
6161struct GhUser {
6162    #[serde(default)]
6163    login: String,
6164}
6165
6166#[derive(Debug, Deserialize)]
6167struct GhInline {
6168    #[serde(default)]
6169    user: GhUser,
6170    #[serde(default)]
6171    path: Option<String>,
6172    #[serde(default)]
6173    line: Option<u64>,
6174    #[serde(default)]
6175    body: String,
6176}
6177
6178impl Default for GhAuthor {
6179    fn default() -> Self {
6180        Self {
6181            login: "(unknown)".to_owned(),
6182        }
6183    }
6184}
6185
6186impl Default for GhUser {
6187    fn default() -> Self {
6188        Self {
6189            login: "(unknown)".to_owned(),
6190        }
6191    }
6192}
6193
6194#[cfg(test)]
6195pub(crate) mod tests {
6196    use super::*;
6197    use crate::run::{Candidate, ReviewRecord, ReviewRound, Tally};
6198
6199    fn head_json(head: &str, base: &str, state: &str, cross: bool) -> String {
6200        format!(
6201            r#"{{"headRefName":"{head}","headRefOid":"aaa","baseRefName":"{base}","state":"{state}","isCrossRepository":{cross}}}"#
6202        )
6203    }
6204
6205    #[test]
6206    fn a_pull_request_is_closed_only_when_its_head_is_exactly_the_runs_branch() {
6207        let ok = head_json("magi/27b2/A", "main", "OPEN", false);
6208        assert_eq!(
6209            closable(&ok, "magi/27b2/A", "main", &["aaa".to_owned()]),
6210            Ok(())
6211        );
6212        for (json, why) in [
6213            (head_json("magi/27b2/B", "main", "OPEN", false), "head"),
6214            (head_json("magi/27b2/A-2", "main", "OPEN", false), "head"),
6215            (head_json("magi/27b2/A", "main", "OPEN", true), "fork"),
6216            (head_json("magi/27b2/A", "dev", "OPEN", false), "targets"),
6217            (head_json("magi/27b2/A", "main", "MERGED", false), "already"),
6218            (head_json("magi/27b2/A", "main", "CLOSED", false), "already"),
6219        ] {
6220            let err = closable(&json, "magi/27b2/A", "main", &["aaa".to_owned()])
6221                .unwrap_err()
6222                .why;
6223            assert!(err.contains(why), "{json}: {err}");
6224        }
6225        // A head that moved on past what was verified is left alone.
6226        let moved = head_json("magi/27b2/A", "main", "OPEN", false);
6227        let err = closable(&moved, "magi/27b2/A", "main", &["bbb".to_owned()]).unwrap_err();
6228        assert!(err.retry && err.why.contains("not a commit"), "{err:?}");
6229        assert!(
6230            !closable(
6231                &head_json("x", "main", "OPEN", false),
6232                "magi/27b2/A",
6233                "main",
6234                &[]
6235            )
6236            .unwrap_err()
6237            .retry
6238        );
6239        assert!(is_forge_url("https://github.com/o/r.git"));
6240        assert!(is_forge_url("git@github.com:o/r.git"));
6241        assert!(!is_forge_url("/tmp/origin.git"));
6242        assert!(!is_forge_url("C:\\work\\origin.git"));
6243        assert!(!is_forge_url("file:///tmp/origin.git"));
6244        assert!(forge_unavailable(
6245            "gh pr list failed: none of the git remotes configured for this repository point to a known GitHub host."
6246        ));
6247        assert!(!forge_unavailable(
6248            "gh pr list failed: error connecting to api.github.com"
6249        ));
6250        assert!(closable("not json", "magi/27b2/A", "main", &[]).is_err());
6251        // A record that does not say whether it is a fork is not trusted.
6252        assert!(
6253            closable(
6254                r#"{"headRefName":"b","headRefOid":"aaa","baseRefName":"main","state":"OPEN"}"#,
6255                "b",
6256                "main",
6257                &["aaa".to_owned()]
6258            )
6259            .is_err()
6260        );
6261    }
6262
6263    #[test]
6264    fn the_close_comment_names_the_commit_on_the_base() {
6265        let e = crate::already::Evidence {
6266            proof: crate::already::Proof::PatchId,
6267            tip: "1234567890".to_owned(),
6268            commits: vec!["0e368de0000".to_owned()],
6269        };
6270        let c = superseded_comment("main", &e);
6271        assert!(c.contains("0e368de") && c.contains("`main`"), "{c}");
6272    }
6273
6274    /// 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.
6275    const GREEN_OPEN: &str = r####"{
6276  "url": "https://github.com/yukimemi/magi/pull/10",
6277  "number": 10,
6278  "state": "OPEN",
6279  "mergeStateStatus": "CLEAN",
6280  "statusCheckRollup": [
6281    {
6282      "__typename": "CheckRun",
6283      "conclusion": "SKIPPED",
6284      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278334/job/99378963755",
6285      "name": "review",
6286      "status": "COMPLETED",
6287      "workflowName": "claude-review"
6288    },
6289    {
6290      "__typename": "CheckRun",
6291      "conclusion": "SUCCESS",
6292      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963144",
6293      "name": "check (ubuntu-latest)",
6294      "status": "COMPLETED",
6295      "workflowName": "CI"
6296    },
6297    {
6298      "__typename": "CheckRun",
6299      "conclusion": "SUCCESS",
6300      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33356278338/job/99378963095",
6301      "name": "rustfmt",
6302      "status": "COMPLETED",
6303      "workflowName": "CI"
6304    },
6305    {
6306      "__typename": "StatusContext",
6307      "context": "CodeRabbit",
6308      "state": "SUCCESS",
6309      "targetUrl": ""
6310    }
6311  ],
6312  "reviews": [],
6313  "comments": [
6314    {
6315      "author": {
6316        "login": "coderabbitai"
6317      },
6318      "authorAssociation": "NONE",
6319      "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"
6320    }
6321  ]
6322}"####;
6323
6324    /// Real output for the open pull request #9 (the daily kata-apply), whose `editorconfig` check failed while everything else passed.
6325    const RED_OPEN: &str = r####"{
6326  "url": "https://github.com/yukimemi/magi/pull/9",
6327  "number": 9,
6328  "state": "OPEN",
6329  "mergeStateStatus": "UNSTABLE",
6330  "statusCheckRollup": [
6331    {
6332      "__typename": "CheckRun",
6333      "conclusion": "SUCCESS",
6334      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6335      "name": "check (ubuntu-latest)",
6336      "status": "COMPLETED",
6337      "workflowName": "CI"
6338    },
6339    {
6340      "__typename": "CheckRun",
6341      "conclusion": "SUCCESS",
6342      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6343      "name": "rustfmt",
6344      "status": "COMPLETED",
6345      "workflowName": "CI"
6346    },
6347    {
6348      "__typename": "CheckRun",
6349      "conclusion": "FAILURE",
6350      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6351      "name": "editorconfig",
6352      "status": "COMPLETED",
6353      "workflowName": "CI"
6354    },
6355    {
6356      "__typename": "StatusContext",
6357      "context": "CodeRabbit",
6358      "state": "SUCCESS",
6359      "targetUrl": ""
6360    }
6361  ],
6362  "reviews": [],
6363  "comments": [
6364    {
6365      "author": {
6366        "login": "coderabbitai"
6367      },
6368      "authorAssociation": "NONE",
6369      "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"
6370    }
6371  ]
6372}"####;
6373
6374    /// 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.
6375    const PENDING_OPEN: &str = r####"{
6376  "url": "https://github.com/yukimemi/magi/pull/9",
6377  "number": 9,
6378  "state": "OPEN",
6379  "statusCheckRollup": [
6380    {
6381      "__typename": "CheckRun",
6382      "conclusion": "SUCCESS",
6383      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323744",
6384      "name": "check (ubuntu-latest)",
6385      "status": "COMPLETED",
6386      "workflowName": "CI"
6387    },
6388    {
6389      "__typename": "CheckRun",
6390      "conclusion": "SUCCESS",
6391      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323811",
6392      "name": "rustfmt",
6393      "status": "COMPLETED",
6394      "workflowName": "CI"
6395    },
6396    {
6397      "__typename": "CheckRun",
6398      "conclusion": null,
6399      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572",
6400      "name": "editorconfig",
6401      "status": "IN_PROGRESS",
6402      "workflowName": "CI"
6403    },
6404    {
6405      "__typename": "StatusContext",
6406      "context": "CodeRabbit",
6407      "state": "SUCCESS",
6408      "targetUrl": ""
6409    }
6410  ],
6411  "reviews": [],
6412  "comments": []
6413}"####;
6414
6415    /// Real output for pull request #16 after it was merged - the shape landing sees when a person merged underneath it.
6416    const MERGED: &str = r####"{
6417  "url": "https://github.com/yukimemi/magi/pull/16",
6418  "number": 16,
6419  "state": "MERGED",
6420  "statusCheckRollup": [
6421    {
6422      "__typename": "CheckRun",
6423      "conclusion": "SUCCESS",
6424      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587933/job/100268878095",
6425      "name": "check (ubuntu-latest)",
6426      "status": "COMPLETED",
6427      "workflowName": "CI"
6428    },
6429    {
6430      "__typename": "CheckRun",
6431      "conclusion": "SUCCESS",
6432      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33636587918/job/100268876427",
6433      "name": "review",
6434      "status": "COMPLETED",
6435      "workflowName": "claude-review"
6436    }
6437  ],
6438  "reviews": [],
6439  "comments": []
6440}"####;
6441
6442    /// 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.
6443    const REVIEWED_OPEN: &str = r####"{
6444  "url": "https://github.com/yukimemi/magi/pull/12",
6445  "number": 12,
6446  "state": "OPEN",
6447  "statusCheckRollup": [
6448    {
6449      "__typename": "CheckRun",
6450      "conclusion": "SUCCESS",
6451      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212506/job/100065355258",
6452      "name": "check (ubuntu-latest)",
6453      "status": "COMPLETED",
6454      "workflowName": "CI"
6455    },
6456    {
6457      "__typename": "CheckRun",
6458      "conclusion": "SUCCESS",
6459      "detailsUrl": "https://github.com/yukimemi/magi/actions/runs/33571212566/job/100065355810",
6460      "name": "review",
6461      "status": "COMPLETED",
6462      "workflowName": "claude-review"
6463    }
6464  ],
6465  "reviews": [
6466    {
6467      "author": {
6468        "login": "claude"
6469      },
6470      "state": "COMMENTED",
6471      "body": ""
6472    }
6473  ],
6474  "comments": [
6475    {
6476      "author": {
6477        "login": "coderabbitai"
6478      },
6479      "authorAssociation": "NONE",
6480      "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"
6481    },
6482    {
6483      "author": {
6484        "login": "claude"
6485      },
6486      "authorAssociation": "NONE",
6487      "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"
6488    }
6489  ]
6490}"####;
6491
6492    /// Real `gh api repos/{owner}/{repo}/pulls/12/comments` output: one inline finding with its file and line.
6493    const INLINE: &str = r####"[
6494  {
6495    "user": {
6496      "login": "claude[bot]"
6497    },
6498    "path": "src/graph.rs",
6499    "line": 231,
6500    "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"
6501  }
6502]"####;
6503
6504    /// CodeRabbit's real trigger notice: a checkbox, a `<details>` block, and its own "skip review" marker.
6505    const CODERABBIT_TRIGGER: &str = r####"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->
6506<!-- This is an auto-generated comment: skip review by coderabbit.ai -->
6507
6508> [!IMPORTANT]
6509> - [ ] <!-- {"checkboxId":"e9bb8d72-00e8-4f67-9cb2-caf3b22574fe"} --> 🔍 Trigger review
6510> 
6511> This repository does not receive automatic reviews because it has fewer than 10 stars.
6512> 
6513> <details>
6514> <summary>⚙️ Run configuration</summary>
6515> 
6516> **Configuration used**: defaults
6517> 
6518> **Review profile**: CHILL
6519> 
6520> **Plan**: Team
6521> 
6522> **Run ID**: `c1e2a68f-87fc-4b35-9ec4-e75c7854966a`
6523> 
6524> </details>
6525
6526<!-- end of auto-generated comment: skip review by coderabbit.ai -->
6527
6528<!-- tips_start -->
6529
6530---
6531
6532Thanks 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.
6533
6534<details>
6535<summary>❤️ Share</summary>
6536
6537- [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"####;
6538
6539    /// 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.
6540    const CLAUDE_CHECKLIST: &str = r####"**Claude finished @yukimemi's task in 4m 14s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33636587918)
6541
6542---
6543### Reviewing PR #16
6544
6545- [x] Read AGENTS.md conventions
6546- [x] Review `src/daemon.rs` changes
6547- [x] Review `src/main.rs` changes (new `doctor` reporting)
6548- [x] Review `src/web.rs` changes (reuse of unreadable-run count)
6549- [x] Check test coverage for new behavior
6550- [x] Run verification commands (blocked — see note)
6551- [x] Post findings"####;
6552
6553    /// The same job's real comment on pull request #12 once it had something to say.
6554    const CLAUDE_FINDING: &str = r####"**Claude finished @yukimemi's task in 3m 52s** —— [View job](https://github.com/yukimemi/magi/actions/runs/33571212566)
6555
6556---
6557### Review: `magi review <branch>` — cheap-half-only graph
6558
6559Read 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.
6560
6561**Correctness**
6562
6563- 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"####;
6564
6565    fn pr(checks: Checks, failing: &[&str], comments: usize) -> PrState {
6566        PrState {
6567            url: "https://github.com/yukimemi/magi/pull/16".to_owned(),
6568            number: 16,
6569            state: PrLifecycle::Open,
6570            checks,
6571            // These tests are about red-means-fix, so a red here is one the
6572            // forge gates on. Without saying so they would assert the new
6573            // "merge past a check nobody requires" path by accident.
6574            blocking: if matches!(checks, Checks::Red) {
6575                Blocking::Yes
6576            } else {
6577                Blocking::No
6578            },
6579            failing: failing.iter().map(|s| (*s).to_owned()).collect(),
6580            review_comments: (0..comments)
6581                .map(|i| ReviewComment {
6582                    author: "coderabbitai".to_owned(),
6583                    path: Some("src/graph.rs".to_owned()),
6584                    line: Some(231),
6585                    body: format!("finding {i}"),
6586                })
6587                .collect(),
6588        }
6589    }
6590
6591    #[test]
6592    fn expected_ci_is_exactly_decide_and_absent_ci_never_waits_for_checks() {
6593        use CiExpectation::{Absent, Expected};
6594        for checks in [Checks::Pending, Checks::Unknown, Checks::Green, Checks::Red] {
6595            let p = pr(checks, &["x"], 0);
6596            for waited in [Duration::ZERO, CHECKS_GRACE] {
6597                assert_eq!(
6598                    decide_with(&p, 0, 4, waited, Expected),
6599                    decide(&p, 0, 4, waited)
6600                );
6601            }
6602        }
6603        // Nothing will ever report: no wait, no give-up, no fix round.
6604        for checks in [Checks::Pending, Checks::Unknown, Checks::Red] {
6605            let p = pr(checks, &["x"], 0);
6606            assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Merge);
6607            assert_eq!(decide_with(&p, 4, 4, CHECKS_GRACE, Absent), Step::Merge);
6608        }
6609        // Third-party statuses never change the review decision, even at budget.
6610        for comments in [0, 1] {
6611            for round in [0, 4] {
6612                let absent = decide_with(
6613                    &pr(Checks::Unknown, &[], comments),
6614                    round,
6615                    4,
6616                    CHECKS_GRACE,
6617                    Absent,
6618                );
6619                for checks in [Checks::Green, Checks::Red, Checks::Pending] {
6620                    assert_eq!(
6621                        decide_with(
6622                            &pr(checks, &["third-party"], comments),
6623                            round,
6624                            4,
6625                            Duration::ZERO,
6626                            Absent
6627                        ),
6628                        absent
6629                    );
6630                }
6631                if comments != 0 {
6632                    assert!(matches!(absent, Step::Fix { .. } | Step::GiveUp { .. }));
6633                    assert_eq!(matches!(absent, Step::GiveUp { .. }), round == 4);
6634                }
6635            }
6636        }
6637        let empty = pr(Checks::Unknown, &[], 0);
6638        for waited in [Duration::ZERO, CHECKS_GRACE + POLL] {
6639            assert_eq!(decide_with(&empty, 0, 4, waited, Absent), Step::Merge);
6640        }
6641        assert_eq!(
6642            decide_with(&empty, 0, 4, Duration::ZERO, Expected),
6643            Step::Wait
6644        );
6645        assert_eq!(
6646            decide_with(&empty, 0, 4, CHECKS_GRACE, Expected),
6647            Step::GiveUp { reason: "no check status is readable on the pull request after 3 minute(s); refusing to merge on a guess".to_owned() }
6648        );
6649        // A conflict is not a check: it still wants the rebase.
6650        let mut p = pr(Checks::Unknown, &[], 0);
6651        p.blocking = Blocking::Conflict;
6652        assert_eq!(decide_with(&p, 0, 4, Duration::ZERO, Absent), Step::Rebase);
6653        // And a pull request that left our hands is still done.
6654        p.state = PrLifecycle::Merged;
6655        assert_eq!(
6656            decide_with(&p, 0, 4, Duration::ZERO, Absent),
6657            Step::Done { merged: true }
6658        );
6659    }
6660
6661    #[test]
6662    fn a_green_pull_request_with_nothing_outstanding_parses_as_ready_to_merge() {
6663        let state = parse_pr(GREEN_OPEN).expect("green fixture parses");
6664        assert_eq!(state.number, 10);
6665        assert_eq!(state.state, PrLifecycle::Open);
6666        assert_eq!(state.checks, Checks::Green);
6667        assert!(state.failing.is_empty());
6668        assert!(
6669            state.review_comments.is_empty(),
6670            "the only comment is CodeRabbit's trigger notice: {:?}",
6671            state.review_comments
6672        );
6673        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Merge);
6674    }
6675
6676    #[test]
6677    fn a_failing_check_parses_as_red_and_is_named() {
6678        let state = parse_pr(RED_OPEN).expect("red fixture parses");
6679        assert_eq!(state.checks, Checks::Red);
6680        assert_eq!(state.failing, vec!["editorconfig".to_owned()]);
6681        // The captured payload says `UNSTABLE` - mergeable, with a check
6682        // nobody requires red - which is exactly the shape that had to be
6683        // merged by hand. Asserted separately, in
6684        // `a_red_check_nobody_requires_does_not_buy_a_fix_round`. What this
6685        // test is about is that a red check is *named*, so the reason a fixer
6686        // is handed says which one; so it asks the blocking question here.
6687        let mut blocking = state.clone();
6688        blocking.blocking = Blocking::Yes;
6689        match decide(&blocking, 0, 4, Duration::ZERO) {
6690            Step::Fix { reason } => {
6691                assert!(reason.contains("editorconfig"), "reason: {reason}");
6692                assert!(reason.contains("failing"), "reason: {reason}");
6693            }
6694            other => panic!("expected a fix round, got {other:?}"),
6695        }
6696    }
6697
6698    #[test]
6699    fn a_check_still_running_parses_as_pending_and_is_waited_for() {
6700        let state = parse_pr(PENDING_OPEN).expect("pending fixture parses");
6701        assert_eq!(state.checks, Checks::Pending);
6702        assert_eq!(decide(&state, 0, 4, Duration::ZERO), Step::Wait);
6703    }
6704
6705    #[test]
6706    fn a_pull_request_merged_underneath_us_is_done_rather_than_a_failure() {
6707        let state = parse_pr(MERGED).expect("merged fixture parses");
6708        assert_eq!(state.state, PrLifecycle::Merged);
6709        assert_eq!(
6710            decide(&state, 0, 4, Duration::ZERO),
6711            Step::Done { merged: true }
6712        );
6713    }
6714
6715    #[test]
6716    fn a_review_that_found_something_is_outstanding_and_holds_the_merge() {
6717        let state = parse_pr(REVIEWED_OPEN).expect("reviewed fixture parses");
6718        assert_eq!(state.checks, Checks::Green);
6719        let authors: Vec<&str> = state
6720            .review_comments
6721            .iter()
6722            .map(|c| c.author.as_str())
6723            .collect();
6724        assert_eq!(
6725            authors,
6726            vec!["claude"],
6727            "CodeRabbit's walkthrough is machinery; Claude's review is a finding"
6728        );
6729        match decide(&state, 0, 4, Duration::ZERO) {
6730            Step::Fix { reason } => assert!(reason.contains("unresolved"), "reason: {reason}"),
6731            other => panic!("expected a fix round, got {other:?}"),
6732        }
6733    }
6734
6735    #[test]
6736    fn inline_review_comments_keep_their_file_and_line() {
6737        let comments = parse_inline_comments(INLINE).expect("inline fixture parses");
6738        assert_eq!(comments.len(), 1);
6739        assert_eq!(comments[0].author, "claude[bot]");
6740        assert_eq!(comments[0].path.as_deref(), Some("src/graph.rs"));
6741        assert_eq!(comments[0].line, Some(231));
6742        assert!(comments[0].body.contains("empty"), "{}", comments[0].body);
6743    }
6744
6745    #[test]
6746    fn a_status_only_bot_comment_does_not_trigger_a_fix_round() {
6747        assert!(
6748            is_noise(CODERABBIT_TRIGGER),
6749            "CodeRabbit's trigger notice declares itself not a review"
6750        );
6751        assert!(
6752            is_noise(CLAUDE_CHECKLIST),
6753            "a progress checklist asks for nothing"
6754        );
6755        assert!(
6756            !is_noise(CLAUDE_FINDING),
6757            "a review that names a bug is input, not noise"
6758        );
6759
6760        let mut clean = pr(Checks::Green, &[], 0);
6761        clean.review_comments.push(ReviewComment {
6762            author: "coderabbitai".to_owned(),
6763            path: None,
6764            line: None,
6765            body: CODERABBIT_TRIGGER.to_owned(),
6766        });
6767        clean.review_comments.retain(|c| !is_noise(&c.body));
6768        assert_eq!(decide(&clean, 0, 4, Duration::ZERO), Step::Merge);
6769
6770        let mut found = pr(Checks::Green, &[], 0);
6771        found.review_comments.push(ReviewComment {
6772            author: "claude".to_owned(),
6773            path: None,
6774            line: None,
6775            body: CLAUDE_FINDING.to_owned(),
6776        });
6777        found.review_comments.retain(|c| !is_noise(&c.body));
6778        assert!(matches!(
6779            decide(&found, 0, 4, Duration::ZERO),
6780            Step::Fix { .. }
6781        ));
6782    }
6783
6784    #[test]
6785    fn the_policy_table_holds_for_every_combination_that_matters() {
6786        let cases: Vec<(&str, PrState, usize, usize, Duration, Step)> = vec![
6787            (
6788                "pending checks are waited for, even on the last round",
6789                pr(Checks::Pending, &[], 0),
6790                4,
6791                4,
6792                Duration::ZERO,
6793                Step::Wait,
6794            ),
6795            (
6796                "red checks are fixed",
6797                pr(Checks::Red, &["editorconfig"], 0),
6798                0,
6799                4,
6800                Duration::ZERO,
6801                Step::Fix {
6802                    reason: "1 check(s) failing: editorconfig".to_owned(),
6803                },
6804            ),
6805            (
6806                "green with comments is fixed, not merged",
6807                pr(Checks::Green, &[], 2),
6808                1,
6809                4,
6810                Duration::ZERO,
6811                Step::Fix {
6812                    reason: "checks are green but 2 review comment(s) are unresolved: coderabbitai"
6813                        .to_owned(),
6814                },
6815            ),
6816            (
6817                "green and clean merges",
6818                pr(Checks::Green, &[], 0),
6819                3,
6820                4,
6821                Duration::ZERO,
6822                Step::Merge,
6823            ),
6824            (
6825                "an unreadable rollup is waited on while the grace lasts",
6826                pr(Checks::Unknown, &[], 0),
6827                0,
6828                4,
6829                Duration::ZERO,
6830                Step::Wait,
6831            ),
6832            (
6833                "an unreadable rollup is never merged once the grace is spent",
6834                pr(Checks::Unknown, &[], 0),
6835                0,
6836                4,
6837                CHECKS_GRACE,
6838                Step::GiveUp {
6839                    reason: "no check status is readable on the pull request after 3 minute(s); \
6840                             refusing to merge on a guess"
6841                        .to_owned(),
6842                },
6843            ),
6844        ];
6845        for (what, state, round, budget, waited, want) in cases {
6846            assert_eq!(decide(&state, round, budget, waited), want, "{what}");
6847        }
6848    }
6849
6850    #[test]
6851    fn the_forge_verdict_survives_the_round_trip_from_gh() {
6852        // Read off `gh pr view --json ...,mergeStateStatus`, because a field
6853        // requested but never parsed is the kind of thing that looks wired up
6854        // and answers `Unsaid` forever.
6855        let green = parse_pr(GREEN_OPEN).expect("parse");
6856        assert_eq!(green.blocking, Blocking::No);
6857        let red = parse_pr(RED_OPEN).expect("parse");
6858        assert_eq!(
6859            red.blocking,
6860            Blocking::No,
6861            "`UNSTABLE` is mergeable: the red check is one nobody requires"
6862        );
6863        assert_eq!(red.checks, Checks::Red, "and it is still reported as red");
6864        // A payload from an older `gh` has no such field at all.
6865        let quiet =
6866            parse_pr(&GREEN_OPEN.replace("\"mergeStateStatus\": \"CLEAN\",", "")).expect("parse");
6867        assert_eq!(quiet.blocking, Blocking::Unsaid);
6868    }
6869
6870    #[test]
6871    fn a_red_check_nobody_requires_does_not_buy_a_fix_round() {
6872        // Pull request 37's only red check was `editorconfig`, failing
6873        // because the action could not fetch its own binary after
6874        // editorconfig-checker v4 renamed its release assets. The repository
6875        // does not require it. magi answered by asking a fixer to repair a
6876        // change that was fine, and the pull request had to be merged by hand.
6877        let mut nonblocking = pr(Checks::Red, &["editorconfig", "coverage"], 0);
6878        nonblocking.blocking = Blocking::No;
6879        assert_eq!(
6880            decide(&nonblocking, 0, 4, Duration::ZERO),
6881            Step::Merge,
6882            "the forge says nothing is in the way, so nothing is"
6883        );
6884
6885        // The same red, gated on: that is a fix round, as before.
6886        let mut blocking = pr(Checks::Red, &["test (ubuntu-latest)"], 0);
6887        blocking.blocking = Blocking::Yes;
6888        assert!(matches!(
6889            decide(&blocking, 0, 4, Duration::ZERO),
6890            Step::Fix { .. }
6891        ));
6892
6893        // A review comment still outranks green-enough: a non-required red
6894        // must not become a way to merge past an unanswered reviewer.
6895        let mut commented = pr(Checks::Red, &["coverage"], 1);
6896        commented.blocking = Blocking::No;
6897        assert!(matches!(
6898            decide(&commented, 0, 4, Duration::ZERO),
6899            Step::Fix { .. }
6900        ));
6901
6902        // And silence from the forge is not consent.
6903        let mut unsaid = pr(Checks::Red, &["coverage"], 0);
6904        unsaid.blocking = Blocking::Unsaid;
6905        assert_eq!(decide(&unsaid, 0, 4, Duration::ZERO), Step::Wait);
6906        assert!(matches!(
6907            decide(&unsaid, 0, 4, CHECKS_GRACE),
6908            Step::Fix { .. }
6909        ));
6910    }
6911
6912    #[test]
6913    fn a_red_merge_is_announced_with_every_failing_check_and_a_green_one_is_not() {
6914        let mut red = pr(Checks::Red, &["test (windows-latest)", "coverage"], 0);
6915        red.blocking = Blocking::No;
6916        assert_eq!(
6917            decide(&red, 0, 4, Duration::ZERO),
6918            Step::Merge,
6919            "announcing must not change the decision"
6920        );
6921        let said = red_merge_summary("yukimemi/magi", &red).expect("red merge is announced");
6922        assert!(said.contains("yukimemi/magi"), "{said}");
6923        assert!(said.contains("#16"), "{said}");
6924        assert!(
6925            said.contains("https://github.com/yukimemi/magi/pull/16"),
6926            "{said}"
6927        );
6928        assert!(
6929            said.contains("test (windows-latest)") && said.contains("coverage"),
6930            "{said}"
6931        );
6932
6933        // `failing` can be left over on a green observation; only `checks` counts.
6934        let green = pr(Checks::Green, &["stale"], 0);
6935        assert_eq!(red_merge_summary("yukimemi/magi", &green), None);
6936    }
6937
6938    #[test]
6939    fn red_merge_notices_omit_empty_check_details() {
6940        for checks in [vec![], vec!["", "  "]] {
6941            let red = pr(Checks::Red, &checks, 0);
6942            let summary = red_merge_summary("owner/repo", &red).unwrap();
6943            let notice = crate::notices::merged_red("run-1", &summary);
6944            assert_eq!(
6945                notice.message,
6946                format!("Merged owner/repo PR #16 with red checks ({})", red.url)
6947            );
6948            assert_eq!(notice.severity, crate::notices::Severity::Warn);
6949        }
6950        let red = pr(Checks::Red, &["", " test ", " "], 0);
6951        let summary = red_merge_summary("owner/repo", &red).unwrap();
6952        assert!(summary.contains("with red checks: test ("), "{summary}");
6953    }
6954
6955    #[test]
6956    fn the_repo_label_comes_from_the_pull_request_url() {
6957        let p = Path::new("/tmp/checkout");
6958        assert_eq!(
6959            repo_label(p, "https://github.com/yukimemi/magi/pull/16"),
6960            "yukimemi/magi"
6961        );
6962        assert_eq!(repo_label(p, "not a url"), "checkout");
6963    }
6964
6965    #[test]
6966    fn a_branch_the_base_moved_under_is_rebased_not_fixed() {
6967        // Pull requests 35 and 37 were both rebased by hand: a competition
6968        // that runs for two hours against a repository merging pull requests
6969        // all day conflicts on the way in, and that is arithmetic rather
6970        // than a defect in the change.
6971        let mut conflicted = pr(Checks::Green, &[], 0);
6972        conflicted.blocking = Blocking::Conflict;
6973        assert_eq!(decide(&conflicted, 0, 4, Duration::ZERO), Step::Rebase);
6974
6975        // Decided before the checks, and even with the rounds spent: every
6976        // check on a branch that cannot land is an answer about a state that
6977        // cannot land, and a conflict is not the change's fault.
6978        let mut red = pr(Checks::Red, &["test (ubuntu-latest)"], 2);
6979        red.blocking = Blocking::Conflict;
6980        assert_eq!(decide(&red, 4, 4, Duration::ZERO), Step::Rebase);
6981
6982        // The lifecycle still wins over everything, conflict included.
6983        let mut merged = pr(Checks::Red, &[], 0);
6984        merged.blocking = Blocking::Conflict;
6985        merged.state = PrLifecycle::Merged;
6986        assert_eq!(
6987            decide(&merged, 0, 4, Duration::ZERO),
6988            Step::Done { merged: true }
6989        );
6990    }
6991
6992    #[test]
6993    fn the_forge_verdict_is_read_off_merge_state_status() {
6994        // The spellings that mean "mergeable". `UNSTABLE` is the one that
6995        // matters: mergeable, with a non-required check red or still running.
6996        for ok in ["CLEAN", "UNSTABLE", "unstable", "HAS_HOOKS"] {
6997            assert_eq!(Blocking::of(ok), Blocking::No, "{ok}");
6998            assert!(!Blocking::of(ok).stops_a_merge(), "{ok}");
6999        }
7000        assert_eq!(Blocking::of("DIRTY"), Blocking::Conflict);
7001        assert_eq!(Blocking::of("BLOCKED"), Blocking::Yes);
7002        assert_eq!(Blocking::of("BEHIND"), Blocking::Yes);
7003        // An older `gh`, or a token without the scope, says nothing - and
7004        // refusing to guess is the rule everywhere else in this module.
7005        for quiet in ["", "UNKNOWN"] {
7006            assert_eq!(Blocking::of(quiet), Blocking::Unsaid);
7007            assert!(Blocking::of(quiet).stops_a_merge());
7008        }
7009    }
7010
7011    #[test]
7012    fn a_merge_command_that_failed_after_merging_is_still_a_merge() {
7013        let argv = merge_argv(28, "fix: retry uploads on transient network errors");
7014        // The exact stderr from run ec12, in a jj-colocated repository.
7015        let jj = "could not determine current branch: failed to run git: not on any branch";
7016
7017        let landed = merged_after_all(&argv, jj, Some(PrLifecycle::Merged))
7018            .expect("the forge says merged, so it merged");
7019        assert!(landed.ok);
7020        assert!(
7021            landed.detail.contains("but the pull request is merged"),
7022            "the record must not read as a clean success: {}",
7023            landed.detail
7024        );
7025        assert!(
7026            landed.detail.contains("not on any branch"),
7027            "and it must keep what the command actually said: {}",
7028            landed.detail
7029        );
7030
7031        // A pull request still open means the merge really failed.
7032        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Open)).is_none());
7033        assert!(merged_after_all(&argv, jj, Some(PrLifecycle::Closed)).is_none());
7034        // And an unreadable answer is not evidence of success.
7035        assert!(merged_after_all(&argv, jj, None).is_none());
7036    }
7037
7038    #[test]
7039    fn a_pull_request_closed_underneath_us_is_done_and_not_merged() {
7040        let mut state = pr(Checks::Red, &["editorconfig"], 3);
7041        state.state = PrLifecycle::Closed;
7042        assert_eq!(
7043            decide(&state, 0, 4, Duration::ZERO),
7044            Step::Done { merged: false },
7045            "a human closing the pull request ends the loop, whatever CI says"
7046        );
7047    }
7048
7049    #[test]
7050    fn the_last_round_gives_up_with_a_reason_naming_what_is_still_failing() {
7051        let red = decide(
7052            &pr(Checks::Red, &["editorconfig", "test (macos)"], 0),
7053            4,
7054            4,
7055            Duration::ZERO,
7056        );
7057        match red {
7058            Step::GiveUp { reason } => {
7059                assert!(reason.contains("editorconfig"), "reason: {reason}");
7060                assert!(reason.contains("test (macos)"), "reason: {reason}");
7061                assert!(reason.contains("4 fix round(s)"), "reason: {reason}");
7062            }
7063            other => panic!("expected a give-up, got {other:?}"),
7064        }
7065
7066        let commented = decide(&pr(Checks::Green, &[], 1), 2, 2, Duration::ZERO);
7067        match commented {
7068            Step::GiveUp { reason } => {
7069                assert!(reason.contains("unresolved"), "reason: {reason}");
7070                assert!(reason.contains("2 fix round(s)"), "reason: {reason}");
7071            }
7072            other => panic!("expected a give-up, got {other:?}"),
7073        }
7074    }
7075
7076    #[test]
7077    fn the_merge_command_squashes_deletes_the_branch_and_sets_its_own_subject() {
7078        let candidate_commit = "magi: candidate A (uncommitted work)";
7079        let subject = merge_subject(candidate_commit, "add retries to the uploader");
7080        let argv = merge_argv(16, &subject);
7081
7082        assert!(argv.contains(&"--squash".to_owned()));
7083        assert!(argv.contains(&"--delete-branch".to_owned()));
7084        assert!(argv.contains(&"--subject".to_owned()));
7085        assert_eq!(
7086            argv.last().map(String::as_str),
7087            Some("add retries to the uploader"),
7088            "the subject must not be the candidate commit message"
7089        );
7090        assert_ne!(subject, candidate_commit);
7091    }
7092
7093    #[test]
7094    fn a_real_pull_request_title_is_used_as_the_squash_subject_verbatim() {
7095        assert_eq!(
7096            merge_subject("feat: a queue, an unattended loop, and a phone UI", "task"),
7097            "feat: a queue, an unattended loop, and a phone UI"
7098        );
7099        assert_eq!(
7100            merge_subject("", "# port the retry logic\n\ndetails"),
7101            "port the retry logic",
7102            "an empty title falls back to the task's first line, heading marks stripped"
7103        );
7104    }
7105
7106    #[test]
7107    fn a_failing_checks_details_url_yields_the_job_to_read_logs_from() {
7108        let url = "https://github.com/yukimemi/magi/actions/runs/33587406996/job/100114323572";
7109        assert_eq!(job_of(url).as_deref(), Some("100114323572"));
7110        assert_eq!(run_of(url).as_deref(), Some("33587406996"));
7111        assert_eq!(job_of("https://coderabbit.ai/status"), None);
7112        assert_eq!(run_of(""), None);
7113    }
7114
7115    #[test]
7116    fn magis_own_stop_comment_is_never_read_back_as_a_finding() {
7117        let mut out = Vec::new();
7118        push_if_outstanding(
7119            &mut out,
7120            ReviewComment {
7121                author: "yukimemi".to_owned(),
7122                path: None,
7123                line: None,
7124                body: format!("{MARKER}\nmagi stopped landing this pull request: 1 check failing"),
7125            },
7126        );
7127        assert!(out.is_empty());
7128    }
7129
7130    /// A run with no tally, so [`RunState::winner`] is `None` and the panel
7131    /// falls back to the repository - which keeps these tests free of a
7132    /// worktree, a `git` invocation and a network.
7133    fn run_state() -> RunState {
7134        let mut state = RunState::new(
7135            std::path::PathBuf::from("/repo/magi"),
7136            "main".to_owned(),
7137            "abcdef1234".to_owned(),
7138            "add retries to the uploader".to_owned(),
7139            crate::config::Config::default(),
7140        );
7141        // These fixtures test execution flow, independent of host disk capacity.
7142        state.config.disk.min_free_bytes = 0;
7143        // Tests run in parallel against one persistent home and the ids
7144        // `RunState::new` draws from the clock can repeat, so two tests would
7145        // share a run's questions. The home also outlives the process, so the
7146        // counter alone would reuse an earlier run's ids.
7147        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
7148        let nanos = std::time::SystemTime::now()
7149            .duration_since(std::time::UNIX_EPOCH)
7150            .map_or(0, |d| d.subsec_nanos() % 1_000_000);
7151        state.id = format!(
7152            "20261004-{nanos:06}-{:04x}",
7153            NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
7154        );
7155        state
7156    }
7157
7158    fn green_pr() -> PrState {
7159        PrState {
7160            url: "https://github.com/yukimemi/magi/pull/42".to_owned(),
7161            number: 42,
7162            state: PrLifecycle::Open,
7163            checks: Checks::Green,
7164            // The forge sees nothing in the way unless a test says otherwise.
7165            blocking: Blocking::No,
7166            failing: Vec::new(),
7167            review_comments: vec![ReviewComment {
7168                author: "coderabbitai".to_owned(),
7169                path: Some("src/land.rs".to_owned()),
7170                line: Some(212),
7171                body: "this branch never checks the exit code".to_owned(),
7172            }],
7173        }
7174    }
7175
7176    #[test]
7177    fn land_fixer_defers_review_and_verification_without_a_cache() {
7178        let state = run_state();
7179        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7180        assert!(p.contains(crate::prompt::child_run_ownership()), "{p}");
7181        assert!(p.contains("Commit in this worktree"), "{p}");
7182    }
7183
7184    #[test]
7185    fn github_facing_land_text_is_english_whatever_the_language() {
7186        let mut state = run_state();
7187        state.config.graph.language = "ja".to_owned();
7188        let comment = stop_comment(&state.id, "checks are still red");
7189        assert!(crate::github_text::check("", &comment).is_empty());
7190        assert!(comment.is_ascii(), "{comment}");
7191        assert!(comment.starts_with(MARKER));
7192
7193        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7194        let ja_at = p.find("Write all prose in ja").unwrap();
7195        let rule_at = p.find(crate::prompt::GITHUB_ENGLISH_HEADING).unwrap();
7196        assert!(ja_at < rule_at, "{p}");
7197        assert!(p.contains("stays in Japanese"), "{p}");
7198
7199        state.config.graph.language = "en".to_owned();
7200        let p = fix_prompt(&state, &green_pr(), 1, 2, "red", "");
7201        assert!(p.contains(crate::prompt::GITHUB_ENGLISH_HEADING), "{p}");
7202        assert!(!p.contains("does not apply"), "{p}");
7203    }
7204
7205    const NUMSTAT: &str = "12\t3\tsrc/land.rs\n40\t1\tsrc/web.rs\n-\t-\tassets/logo.png";
7206
7207    fn panel() -> String {
7208        approval_panel(
7209            &run_state(),
7210            &green_pr(),
7211            NUMSTAT,
7212            "diff --git a/src/land.rs b/src/land.rs\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context",
7213            &[
7214                "land: ask before merging".to_owned(),
7215                "land: colour the diff".to_owned(),
7216            ],
7217            "feat: merge approval from the phone",
7218        )
7219    }
7220
7221    #[test]
7222    fn the_approval_panel_carries_the_whole_case_for_the_merge() {
7223        let html = panel();
7224        for needle in [
7225            "42",
7226            "main",
7227            "src/land.rs",
7228            "src/web.rs",
7229            "assets/logo.png",
7230            "feat: merge approval from the phone",
7231            "land: ask before merging",
7232            "land: colour the diff",
7233            "coderabbitai",
7234            "this branch never checks the exit code",
7235            "green",
7236        ] {
7237            assert!(html.contains(needle), "the panel must state `{needle}`");
7238        }
7239    }
7240
7241    async fn landing_git_fixture() -> (tempfile::TempDir, RunState, String) {
7242        crate::run::pin_test_home();
7243        let tmp = tempfile::tempdir().unwrap();
7244        let repo = tmp.path().join("repo");
7245        let remote = tmp.path().join("remote.git");
7246        std::fs::create_dir(&repo).unwrap();
7247        git::git(tmp.path(), &["init", "--bare", remote.to_str().unwrap()])
7248            .await
7249            .unwrap();
7250        git::git(&repo, &["init", "-b", "main"]).await.unwrap();
7251        git::git(&repo, &["config", "user.name", "Test"])
7252            .await
7253            .unwrap();
7254        git::git(&repo, &["config", "user.email", "test@example.test"])
7255            .await
7256            .unwrap();
7257        std::fs::write(repo.join("base"), "base").unwrap();
7258        git::git(&repo, &["add", "."]).await.unwrap();
7259        git::git(&repo, &["commit", "-m", "base"]).await.unwrap();
7260        git::git(
7261            &repo,
7262            &["remote", "add", "origin", remote.to_str().unwrap()],
7263        )
7264        .await
7265        .unwrap();
7266        git::git(&repo, &["push", "origin", "main"]).await.unwrap();
7267        git::git(&repo, &["checkout", "-b", "winner"])
7268            .await
7269            .unwrap();
7270        std::fs::write(repo.join("change"), "change").unwrap();
7271        git::git(&repo, &["add", "."]).await.unwrap();
7272        git::git(&repo, &["commit", "-m", "change"]).await.unwrap();
7273        let head = git::rev_parse(&repo, "HEAD").await.unwrap();
7274        git::git(&repo, &["push", "origin", "HEAD:refs/heads/task-head"])
7275            .await
7276            .unwrap();
7277        let mut state = run_state();
7278        state.repo = repo.clone();
7279        state.base_commit = git::rev_parse(&repo, "origin/main").await.unwrap();
7280        state.config.graph.land = true;
7281        state.config.graph.land_approval = true;
7282        state.pr_head = Some("task-head".into());
7283        state.pr_pushed_head = Some(head.clone());
7284        state.candidates = vec![winning_candidate("change")];
7285        state.candidates[0].worktree = repo;
7286        state.candidates[0].branch = "winner".into();
7287        state.tally = Some(uncontested_tally());
7288        state.gate_ran = true;
7289        state.gate_head = Some(head.clone());
7290        state.land_sync_pending = true;
7291        state.save().unwrap();
7292        (tmp, state, head)
7293    }
7294
7295    async fn resume_observed_head(state: &mut RunState, head: &str) -> Result<String> {
7296        let forge = Scripted::new(vec![seen(head, Checks::Green, "CLEAN", false)], vec![]);
7297        resume_landing_head(state, head, &green_pr().url, &forge).await
7298    }
7299
7300    async fn advance_landing_base(state: &RunState) {
7301        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7302        std::fs::write(state.repo.join("advance"), "advance").unwrap();
7303        git::git(&state.repo, &["add", "."]).await.unwrap();
7304        git::git(&state.repo, &["commit", "-m", "advance base"])
7305            .await
7306            .unwrap();
7307        git::git(&state.repo, &["push", "origin", "main"])
7308            .await
7309            .unwrap();
7310        git::git(&state.repo, &["checkout", "winner"])
7311            .await
7312            .unwrap();
7313    }
7314
7315    async fn foreign_landing_fixture() -> (tempfile::TempDir, RunState, String, std::path::PathBuf)
7316    {
7317        let (tmp, mut state, old) = landing_git_fixture().await;
7318        let path = tmp.path().join("foreign");
7319        git::worktree_add_branch(&state.repo, &path, "task-head", &old)
7320            .await
7321            .unwrap();
7322        state.foreign_checkout = Some(
7323            crate::foreign_checkout::inspect(&state.repo, "task-head", &path)
7324                .await
7325                .unwrap(),
7326        );
7327        state.config.merge.mode = crate::config::MergeMode::Pr;
7328        state.save().unwrap();
7329        (tmp, state, old, path)
7330    }
7331
7332    #[tokio::test]
7333    async fn foreign_landing_sync_and_interrupted_resumes_preserve_delivery_ancestry() {
7334        for phase in 0..4 {
7335            let (_tmp, mut state, old, path) = foreign_landing_fixture().await;
7336            advance_landing_base(&state).await;
7337            state.config.verify.gate = vec!["test -f advance && test -f change".into()];
7338            if phase == 0 {
7339                // The live Step::Rebase path uses this same synchronization
7340                // and publication pair, without going through resume.
7341                assert_eq!(
7342                    rebase_landing_worktree(&mut state, "origin/main")
7343                        .await
7344                        .unwrap(),
7345                    crate::rebase::Rebased::Applied
7346                );
7347            } else {
7348                interrupt_landing_rebase(&mut state).await;
7349                let repaired_tree = git::tree_of(&state.repo, "HEAD").await.unwrap();
7350                if phase == 2 {
7351                    git::sync_to_head(&state.repo).await.unwrap();
7352                } else if phase == 3 {
7353                    // Crash after joining history, before clearing its saved
7354                    // start. Repeating restoration must not add another merge.
7355                    restore_landing_worktree(&mut state).await.unwrap();
7356                    let joined = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7357                    state.land_rebase_from = Some(old.clone());
7358                    state.save().unwrap();
7359                    restore_landing_worktree(&mut state).await.unwrap();
7360                    assert_eq!(git::rev_parse(&state.repo, "HEAD").await.unwrap(), joined);
7361                }
7362                state.save().unwrap();
7363                state = RunState::load(&state.id).unwrap();
7364                restore_landing_worktree(&mut state).await.unwrap();
7365                assert_eq!(
7366                    git::tree_of(&state.repo, "HEAD").await.unwrap(),
7367                    repaired_tree
7368                );
7369            }
7370            let head = if phase == 0 {
7371                let forge = Scripted::new(vec![seen(&old, Checks::Green, "CLEAN", false)], vec![]);
7372                publish_landing_head(&mut state, &old, &green_pr().url, &forge)
7373                    .await
7374                    .unwrap()
7375            } else {
7376                resume_observed_head(&mut state, &old).await.unwrap()
7377            };
7378            assert!(git::is_ancestor(&state.repo, &old, &head).await);
7379            assert!(git::is_ancestor(&state.repo, "origin/main", &head).await);
7380            assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), head);
7381            assert_eq!(
7382                git::remote_head(&state.repo, "origin", "task-head")
7383                    .await
7384                    .unwrap(),
7385                Some(head.clone())
7386            );
7387            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7388            assert_eq!(state.foreign_checkout.as_ref().unwrap().synced_head, head);
7389            assert!(state.gate_allows_progress());
7390            assert!(state.land_rebase_from.is_none());
7391            assert!(!state.land_sync_pending);
7392            assert!(git::is_clean(&path).await.unwrap());
7393        }
7394    }
7395
7396    #[tokio::test]
7397    async fn foreign_landing_conflict_repair_can_resume_and_publish_without_losing_ancestry() {
7398        let (_tmp, mut state, old, path) = foreign_landing_fixture().await;
7399        git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7400        std::fs::write(state.repo.join("change"), "conflicting base change").unwrap();
7401        git::commit_all(&state.repo, "conflicting base")
7402            .await
7403            .unwrap();
7404        git::git(&state.repo, &["push", "origin", "main"])
7405            .await
7406            .unwrap();
7407        git::git(&state.repo, &["checkout", "winner"])
7408            .await
7409            .unwrap();
7410        landing_gate_fixer(
7411            &mut state,
7412            "cat >/dev/null; printf resolved > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"resolved\"}'",
7413        );
7414        state.config.graph.gate_fix_rounds = 0;
7415        state.config.verify.gate = vec!["exit 1".into()];
7416        assert!(resume_observed_head(&mut state, &old).await.is_err());
7417        assert!(state.rebase_fixes.iter().any(|fix| fix.finished));
7418        let repaired = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7419        assert!(git::is_ancestor(&state.repo, &old, &repaired).await);
7420        assert_eq!(
7421            std::fs::read_to_string(state.repo.join("change")).unwrap(),
7422            "resolved"
7423        );
7424        // A red gate must neither deliver nor publish the repaired tree.
7425        assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), old);
7426        assert_eq!(
7427            git::remote_head(&state.repo, "origin", "task-head")
7428                .await
7429                .unwrap(),
7430            Some(old.clone())
7431        );
7432        state = RunState::load(&state.id).unwrap();
7433        state.config.verify.gate = vec!["grep -q resolved change".into()];
7434        let head = resume_observed_head(&mut state, &old).await.unwrap();
7435        assert_eq!(head, repaired);
7436        assert_eq!(git::rev_parse(&path, "HEAD").await.unwrap(), head);
7437        assert_eq!(
7438            std::fs::read_to_string(path.join("change")).unwrap(),
7439            "resolved"
7440        );
7441        assert_eq!(
7442            git::remote_head(&state.repo, "origin", "task-head")
7443                .await
7444                .unwrap(),
7445            Some(head.clone())
7446        );
7447        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7448        assert!(git::is_ancestor(&state.repo, "origin/main", &head).await);
7449        assert!(git::is_clean(&path).await.unwrap());
7450    }
7451
7452    #[tokio::test]
7453    async fn landing_resume_gates_rebased_sha_and_publishes_it_to_the_existing_head() {
7454        let (_tmp, mut state, old) = landing_git_fixture().await;
7455        advance_landing_base(&state).await;
7456        state.config.verify.gate = vec!["test -f advance".into()];
7457        let head = resume_observed_head(&mut state, &old).await.unwrap();
7458        assert_ne!(head, old);
7459        assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7460        assert!(state.gate_allows_progress());
7461        assert_eq!(
7462            git::remote_head(&state.repo, "origin", "task-head")
7463                .await
7464                .unwrap(),
7465            Some(head.clone())
7466        );
7467        assert_eq!(state.pr_pushed_head, Some(head));
7468        assert!(!state.land_sync_pending);
7469        assert!(state.config.graph.land_approval);
7470    }
7471
7472    fn landing_gate_fixer(state: &mut RunState, script: &str) {
7473        state.config.disk.min_free_bytes = 0;
7474        state.config.graph.gate_fix_rounds = 1;
7475        state.config.agents = vec![crate::config::AgentSpec {
7476            id: "mock".into(),
7477            kind: crate::config::AgentKind::Command,
7478            model: None,
7479            command: vec!["sh".into(), "-c".into(), script.into()],
7480            extra_args: Vec::new(),
7481            env: Default::default(),
7482            prompt_delivery: None,
7483            max_turn_secs: None,
7484        }];
7485        state.candidates[0].agent = "mock".into();
7486    }
7487
7488    #[tokio::test]
7489    async fn landing_resume_revalidates_an_operator_published_winner_extension() {
7490        let (_tmp, mut state, old) = landing_git_fixture().await;
7491        std::fs::write(state.repo.join("change"), "operator update").unwrap();
7492        git::commit_all(&state.repo, "extend winner").await.unwrap();
7493        let updated = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7494        git::git(
7495            &state.repo,
7496            &["push", "origin", "HEAD:refs/heads/task-head"],
7497        )
7498        .await
7499        .unwrap();
7500        state.config.verify.gate = vec!["exit 1".into()];
7501        state.config.graph.gate_fix_rounds = 0;
7502        assert!(resume_observed_head(&mut state, &updated).await.is_err());
7503        assert_ne!(old, updated);
7504        assert_eq!(state.gate_head.as_ref(), Some(&updated));
7505        assert!(!state.gate_allows_progress());
7506        assert!(state.land_sync_pending);
7507        state.config.verify.gate = vec!["grep -q 'operator update' change".into()];
7508        let head = resume_observed_head(&mut state, &updated).await.unwrap();
7509        assert_eq!(head, updated);
7510        assert!(state.gate_allows_progress());
7511        assert!(!state.land_sync_pending);
7512        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7513        land_with(&mut state, &green_pr().url, &forge)
7514            .await
7515            .unwrap();
7516        assert!(state.parked);
7517        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7518        assert!(forge.argvs().is_empty());
7519    }
7520
7521    #[tokio::test]
7522    async fn landing_resume_refuses_remote_only_and_divergent_operator_updates() {
7523        for divergent in [false, true] {
7524            let (_tmp, mut state, old) = landing_git_fixture().await;
7525            if divergent {
7526                git::git(&state.repo, &["reset", "--hard", "origin/main"])
7527                    .await
7528                    .unwrap();
7529            }
7530            std::fs::write(state.repo.join("external"), "external").unwrap();
7531            git::commit_all(&state.repo, "external update")
7532                .await
7533                .unwrap();
7534            let external = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7535            git::git(
7536                &state.repo,
7537                &["push", "--force", "origin", "HEAD:refs/heads/task-head"],
7538            )
7539            .await
7540            .unwrap();
7541            if !divergent {
7542                git::git(&state.repo, &["reset", "--hard", &old])
7543                    .await
7544                    .unwrap();
7545            }
7546            let error = resume_observed_head(&mut state, &external)
7547                .await
7548                .unwrap_err();
7549            assert!(
7550                error
7551                    .to_string()
7552                    .contains("public head moved outside this run")
7553            );
7554            assert_eq!(state.pr_pushed_head.as_ref(), Some(&old));
7555            assert_eq!(
7556                git::remote_head(&state.repo, "origin", "task-head")
7557                    .await
7558                    .unwrap(),
7559                Some(external)
7560            );
7561        }
7562    }
7563
7564    #[tokio::test]
7565    async fn landing_resume_repairs_a_failed_rebased_gate_before_publication() {
7566        let (_tmp, mut state, old) = landing_git_fixture().await;
7567        advance_landing_base(&state).await;
7568        landing_gate_fixer(
7569            &mut state,
7570            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7571        );
7572        state.config.verify.gate =
7573            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7574        state.config.verify.e2e = vec!["test -f advance".into()];
7575        // First save a red gate without spending a fixer round. Resume must
7576        // still offer the remaining budget for this exact same failed head.
7577        state.config.graph.gate_fix_rounds = 0;
7578        assert!(resume_observed_head(&mut state, &old).await.is_err());
7579        assert_eq!(
7580            git::remote_head(&state.repo, "origin", "task-head")
7581                .await
7582                .unwrap(),
7583            Some(old.clone())
7584        );
7585        state.config.graph.gate_fix_rounds = 1;
7586        let head = resume_observed_head(&mut state, &old).await.unwrap();
7587        assert_eq!(state.gate_fixes.len(), 1);
7588        assert!(state.gate_fixes[0].committed);
7589        assert!(state.gate_allows_progress());
7590        assert_eq!(state.gate_head.as_ref(), Some(&head));
7591        assert_eq!(
7592            git::remote_head(&state.repo, "origin", "task-head")
7593                .await
7594                .unwrap(),
7595            Some(head.clone())
7596        );
7597        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7598        land_with(&mut state, &green_pr().url, &forge)
7599            .await
7600            .unwrap();
7601        assert!(state.parked);
7602        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7603        assert!(forge.argvs().is_empty());
7604    }
7605
7606    #[tokio::test]
7607    async fn landing_gate_repairs_stop_at_the_saved_budget_without_publishing() {
7608        let (_tmp, mut state, old) = landing_git_fixture().await;
7609        advance_landing_base(&state).await;
7610        landing_gate_fixer(
7611            &mut state,
7612            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7613        );
7614        state.config.verify.gate = vec!["echo still red; exit 1".into()];
7615        for _ in 0..2 {
7616            assert!(resume_observed_head(&mut state, &old).await.is_err());
7617            assert_eq!(state.gate_fixes.len(), 1);
7618            assert_eq!(
7619                git::remote_head(&state.repo, "origin", "task-head")
7620                    .await
7621                    .unwrap(),
7622                Some(old.clone())
7623            );
7624        }
7625    }
7626
7627    #[tokio::test]
7628    async fn landing_gate_repair_cannot_publish_failed_e2e_on_repeated_resumes() {
7629        let (_tmp, mut state, old) = landing_git_fixture().await;
7630        advance_landing_base(&state).await;
7631        landing_gate_fixer(
7632            &mut state,
7633            "cat >/dev/null; printf fixed > change; printf '{\"addressed\":[],\"rejected\":[],\"notes\":\"fixed\"}'",
7634        );
7635        state.config.verify.gate =
7636            vec!["grep -q fixed change || { echo 'change needs repair'; exit 1; }".into()];
7637        // The repair makes the gate green while breaking an e2e invariant.
7638        state.config.verify.e2e = vec!["grep -q '^change$' change".into()];
7639        for resume in 0..3 {
7640            assert!(resume_observed_head(&mut state, &old).await.is_err());
7641            state = RunState::load(&state.id).unwrap();
7642            let evidence = state.gate_repair_e2e.as_ref().unwrap();
7643            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
7644            assert_eq!(evidence.head, head);
7645            assert!(!evidence.passed);
7646            assert!(evidence.outcomes.iter().any(|o| !o.ok()));
7647            assert_eq!(state.gate_fixes.len(), 1);
7648            assert_eq!(
7649                git::remote_head(&state.repo, "origin", "task-head")
7650                    .await
7651                    .unwrap(),
7652                Some(old.clone())
7653            );
7654            if resume > 0 {
7655                assert!(
7656                    state.gate_allows_progress(),
7657                    "a green gate must not bypass red e2e"
7658                );
7659            }
7660        }
7661        // Once e2e genuinely succeeds on the same SHA, the saved requirement
7662        // can be satisfied without another fixer or approval bypass.
7663        state.config.verify.e2e = vec!["grep -q fixed change".into()];
7664        let head = resume_observed_head(&mut state, &old).await.unwrap();
7665        assert!(state.gate_repair_e2e.as_ref().unwrap().passed);
7666        assert_eq!(state.gate_repair_e2e.as_ref().unwrap().head, head);
7667        assert_eq!(state.gate_fixes.len(), 1);
7668        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7669        land_with(&mut state, &green_pr().url, &forge)
7670            .await
7671            .unwrap();
7672        assert!(state.parked);
7673        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7674        assert!(forge.argvs().is_empty());
7675    }
7676
7677    #[tokio::test]
7678    async fn landing_resumes_recheck_pending_deferred_and_stale_repair_e2e() {
7679        for phase in 0..3 {
7680            let (_tmp, mut state, old) = landing_git_fixture().await;
7681            advance_landing_base(&state).await;
7682            let head = resume_observed_head(&mut state, &old).await.unwrap();
7683            // Persist the shapes left by interruption, resource deferral,
7684            // and a successful check for a different (older) SHA.
7685            state.land_sync_pending = true;
7686            state.config.verify.e2e = vec!["echo e2e is red; exit 1".into()];
7687            state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
7688                head: if phase == 2 {
7689                    old.clone()
7690                } else {
7691                    head.clone()
7692                },
7693                passed: phase == 2,
7694                outcomes: if phase == 1 {
7695                    vec![crate::run::CommandOutcome {
7696                        command: state.config.verify.e2e[0].clone(),
7697                        code: None,
7698                        output_tail: "verification resource unavailable".into(),
7699                        duration_ms: 0,
7700                        resource_blocked: true,
7701                    }]
7702                } else {
7703                    Vec::new()
7704                },
7705            });
7706            state.save().unwrap();
7707            for _ in 0..2 {
7708                state = RunState::load(&state.id).unwrap();
7709                assert!(resume_observed_head(&mut state, &head).await.is_err());
7710                let evidence = state.gate_repair_e2e.as_ref().unwrap();
7711                assert_eq!(evidence.head, head);
7712                assert!(!evidence.passed);
7713                assert!(!evidence.outcomes.is_empty());
7714                assert_eq!(
7715                    git::remote_head(&state.repo, "origin", "task-head")
7716                        .await
7717                        .unwrap(),
7718                    Some(head.clone())
7719                );
7720                assert!(state.land_sync_pending);
7721            }
7722        }
7723    }
7724
7725    async fn interrupt_landing_rebase(state: &mut RunState) {
7726        state.land_rebase_from = Some(git::rev_parse(&state.repo, "HEAD").await.unwrap());
7727        state.save().unwrap();
7728        let scratch = state.dir().join("rebase");
7729        assert_eq!(
7730            crate::rebase::rebase_with_fixer(state, &scratch, "winner", "origin/main")
7731                .await
7732                .unwrap(),
7733            crate::rebase::Rebased::Applied
7734        );
7735        assert!(!git::is_clean(&state.repo).await.unwrap());
7736    }
7737
7738    async fn landing_setup(state: &mut RunState) {
7739        git::git(
7740            &state.repo,
7741            &["config", "extensions.worktreeConfig", "true"],
7742        )
7743        .await
7744        .unwrap();
7745        state.config.worktree.setup =
7746            vec![toml::from_str("run = \"printf secret > .env\"").unwrap()];
7747        crate::worktree_setup::prepare(&state.config, &state.repo, &state.repo)
7748            .await
7749            .unwrap();
7750        state.config.verify.gate = vec!["test -f advance && test -f .env".into()];
7751    }
7752
7753    #[tokio::test]
7754    async fn landing_resume_restores_setup_after_both_fresh_and_interrupted_rebases() {
7755        for phase in 0..3 {
7756            let (_tmp, mut state, old) = landing_git_fixture().await;
7757            advance_landing_base(&state).await;
7758            landing_setup(&mut state).await;
7759            if phase > 0 {
7760                interrupt_landing_rebase(&mut state).await;
7761                if phase == 2 {
7762                    // Crash after clean removed setup products, before setup.
7763                    git::sync_to_head(&state.repo).await.unwrap();
7764                    assert!(!state.repo.join(".env").exists());
7765                }
7766                state = RunState::load(&state.id).unwrap();
7767            }
7768            let head = resume_observed_head(&mut state, &old).await.unwrap();
7769            assert_ne!(head, old);
7770            assert!(state.gate_allows_progress());
7771            assert!(state.land_rebase_from.is_none());
7772            assert_eq!(
7773                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7774                "secret"
7775            );
7776            assert!(git::is_clean(&state.repo).await.unwrap());
7777            assert!(
7778                !git::git(&state.repo, &["ls-tree", "--name-only", "HEAD"])
7779                    .await
7780                    .unwrap()
7781                    .contains(".env")
7782            );
7783        }
7784    }
7785
7786    #[tokio::test]
7787    async fn landing_restoration_protects_ignored_files_outside_setup_products() {
7788        let (_tmp, mut state, _) = landing_git_fixture().await;
7789        advance_landing_base(&state).await;
7790        landing_setup(&mut state).await;
7791        interrupt_landing_rebase(&mut state).await;
7792        // Exercise unusual names without asking Windows to create an invalid
7793        // control-character filename. Unix also covers embedded newlines.
7794        let locals = [
7795            "local notes",
7796            #[cfg(unix)]
7797            "local\nnotes",
7798        ];
7799        let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7800            .await
7801            .unwrap();
7802        std::fs::write(state.repo.join(excludes), "local*\n").unwrap();
7803        for local in locals {
7804            std::fs::write(state.repo.join(local), "keep these notes").unwrap();
7805            assert!(
7806                git::git_raw(&state.repo, &["check-ignore", "--quiet", local])
7807                    .await
7808                    .unwrap()
7809                    .ok()
7810            );
7811        }
7812        restore_landing_worktree(&mut state).await.unwrap();
7813        for local in locals {
7814            assert_eq!(
7815                std::fs::read_to_string(state.repo.join(local)).unwrap(),
7816                "keep these notes"
7817            );
7818        }
7819        assert_eq!(
7820            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7821            "secret"
7822        );
7823        assert!(git::is_clean(&state.repo).await.unwrap());
7824        assert!(state.land_rebase_from.is_none());
7825    }
7826
7827    #[tokio::test]
7828    async fn landing_restoration_rejects_ignored_paths_conflicting_with_the_new_tree() {
7829        for local in ["advance", "advance/notes", "new-directory"] {
7830            let (_tmp, mut state, _) = landing_git_fixture().await;
7831            advance_landing_base(&state).await;
7832            // Also cover a local file blocking a new tracked directory.
7833            git::git(&state.repo, &["checkout", "main"]).await.unwrap();
7834            std::fs::create_dir(state.repo.join("new-directory")).unwrap();
7835            std::fs::write(state.repo.join("new-directory/tracked"), "new").unwrap();
7836            git::git(&state.repo, &["add", "."]).await.unwrap();
7837            git::git(&state.repo, &["commit", "-m", "add directory"])
7838                .await
7839                .unwrap();
7840            git::git(&state.repo, &["push", "origin", "main"])
7841                .await
7842                .unwrap();
7843            git::git(&state.repo, &["checkout", "winner"])
7844                .await
7845                .unwrap();
7846            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7847                .await
7848                .unwrap();
7849            std::fs::write(state.repo.join(excludes), "advance\nnew-directory\n").unwrap();
7850            let path = state.repo.join(local);
7851            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
7852            std::fs::write(&path, "local notes").unwrap();
7853            interrupt_landing_rebase(&mut state).await;
7854            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7855            let checkpoint = state.land_rebase_from.clone();
7856            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7857            assert!(
7858                error.to_string().contains("conflicting with HEAD"),
7859                "{error:#}"
7860            );
7861            assert_eq!(std::fs::read_to_string(&path).unwrap(), "local notes");
7862            assert_eq!(
7863                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7864                before
7865            );
7866            assert_eq!(state.land_rebase_from, checkpoint);
7867        }
7868    }
7869
7870    #[tokio::test]
7871    async fn landing_resume_preserves_ignored_gate_artifacts_without_setup() {
7872        for interrupted in [false, true] {
7873            let (_tmp, mut state, old) = landing_git_fixture().await;
7874            assert!(state.config.worktree.setup.is_empty());
7875            // A gate product exists before the base moves, without setup metadata.
7876            let excludes = git::git(&state.repo, &["rev-parse", "--git-path", "info/exclude"])
7877                .await
7878                .unwrap();
7879            std::fs::write(state.repo.join(excludes), "target/\n").unwrap();
7880            state.config.verify.gate =
7881                vec!["mkdir -p target && printf artifact > target/probe".into()];
7882            state.gate_ran = false;
7883            resume_observed_head(&mut state, &old).await.unwrap();
7884            assert_eq!(
7885                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7886                "artifact"
7887            );
7888            advance_landing_base(&state).await;
7889            if interrupted {
7890                interrupt_landing_rebase(&mut state).await;
7891                state = RunState::load(&state.id).unwrap();
7892            }
7893            state.config.verify.gate =
7894                vec!["test -f advance && test \"$(cat target/probe)\" = artifact".into()];
7895            let head = resume_observed_head(&mut state, &old).await.unwrap();
7896            assert_ne!(head, old);
7897            assert!(state.gate_allows_progress());
7898            assert_eq!(state.gate_head.as_deref(), Some(head.as_str()));
7899            assert_eq!(
7900                std::fs::read_to_string(state.repo.join("target/probe")).unwrap(),
7901                "artifact"
7902            );
7903            assert!(state.land_rebase_from.is_none());
7904            assert_eq!(
7905                git::remote_head(&state.repo, "origin", "task-head")
7906                    .await
7907                    .unwrap(),
7908                Some(head.clone())
7909            );
7910            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
7911            land_with(&mut state, &green_pr().url, &forge)
7912                .await
7913                .unwrap();
7914            assert!(state.parked);
7915            assert_eq!(state.land_approval.as_ref().unwrap().head, head);
7916            assert!(forge.argvs().is_empty());
7917        }
7918    }
7919
7920    #[tokio::test]
7921    #[cfg(unix)]
7922    async fn landing_restoration_waits_for_external_coordination_before_changing_files() {
7923        use std::os::fd::AsRawFd as _;
7924
7925        for interrupted in [false, true] {
7926            let (_tmp, mut state, _) = landing_git_fixture().await;
7927            advance_landing_base(&state).await;
7928            landing_setup(&mut state).await;
7929            if interrupted {
7930                interrupt_landing_rebase(&mut state).await;
7931            }
7932            state.config.worktree.setup =
7933                vec![toml::from_str("run = \"printf restored > .env\"").unwrap()];
7934            let common = crate::repo_lock::common_dir(&state.repo)
7935                .await
7936                .unwrap()
7937                .unwrap();
7938            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7939            state.config.coordination = crate::config::test_coordination();
7940            state.config.graph.timeout_resource_wait = 0;
7941            let external = std::fs::OpenOptions::new()
7942                .read(true)
7943                .write(true)
7944                .create(true)
7945                .truncate(false)
7946                .open(common.join("updater.lock"))
7947                .unwrap();
7948            // SAFETY: the descriptor belongs to `external` and remains open.
7949            let locked =
7950                unsafe { libc::flock(external.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
7951            assert_eq!(locked, 0);
7952            let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
7953            let checkpoint = state.land_rebase_from.clone();
7954            let error = restore_landing_worktree(&mut state).await.unwrap_err();
7955            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
7956            assert_eq!(
7957                git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
7958                before
7959            );
7960            assert_eq!(state.land_rebase_from, checkpoint);
7961            assert_eq!(
7962                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7963                "secret"
7964            );
7965
7966            drop(external);
7967            state.config.graph.timeout_resource_wait = 2;
7968            restore_landing_worktree(&mut state).await.unwrap();
7969            assert!(git::is_clean(&state.repo).await.unwrap());
7970            assert!(state.land_rebase_from.is_none());
7971            assert_eq!(
7972                std::fs::read_to_string(state.repo.join(".env")).unwrap(),
7973                "restored"
7974            );
7975        }
7976    }
7977
7978    #[tokio::test]
7979    #[cfg(unix)]
7980    async fn landing_restoration_inherits_cancellation_while_waiting_for_a_local_seat() {
7981        use std::sync::atomic::{AtomicBool, Ordering};
7982
7983        let (_tmp, mut state, _) = landing_git_fixture().await;
7984        advance_landing_base(&state).await;
7985        landing_setup(&mut state).await;
7986        interrupt_landing_rebase(&mut state).await;
7987        let common = crate::repo_lock::common_dir(&state.repo)
7988            .await
7989            .unwrap()
7990            .unwrap();
7991        std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
7992        state.config.coordination = crate::config::test_coordination();
7993        let holder = crate::repo_lock::SeatLease::for_checkout(
7994            &state.repo,
7995            true,
7996            state.config.coordination.as_ref(),
7997        )
7998        .await
7999        .unwrap();
8000        assert!(holder.is_some());
8001        let cancelled = Arc::new(AtomicBool::new(false));
8002        let signal = cancelled.clone();
8003        let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
8004            signal.load(Ordering::SeqCst)
8005        });
8006        let before = git::git(&state.repo, &["diff", "HEAD"]).await.unwrap();
8007        let checkpoint = state.land_rebase_from.clone();
8008        let (result, ()) = tokio::join!(
8009            tokio::time::timeout(
8010                Duration::from_secs(2),
8011                wait.scope(restore_landing_worktree(&mut state)),
8012            ),
8013            async {
8014                tokio::time::sleep(Duration::from_millis(100)).await;
8015                cancelled.store(true, Ordering::SeqCst);
8016            }
8017        );
8018        let error = result
8019            .expect("cancellation must release the waiting driver")
8020            .unwrap_err();
8021        assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
8022        assert_eq!(
8023            git::git(&state.repo, &["diff", "HEAD"]).await.unwrap(),
8024            before
8025        );
8026        assert_eq!(state.land_rebase_from, checkpoint);
8027        assert_eq!(
8028            std::fs::read_to_string(state.repo.join(".env")).unwrap(),
8029            "secret"
8030        );
8031        drop(holder);
8032        restore_landing_worktree(&mut state).await.unwrap();
8033        assert!(state.land_rebase_from.is_none());
8034        assert!(git::is_clean(&state.repo).await.unwrap());
8035    }
8036
8037    #[tokio::test]
8038    #[cfg(unix)]
8039    async fn landing_verification_inherits_cancellation_while_waiting_for_a_local_seat() {
8040        use std::sync::atomic::{AtomicBool, Ordering};
8041
8042        for e2e in [false, true] {
8043            let (_tmp, mut state, head) = landing_git_fixture().await;
8044            let common = crate::repo_lock::common_dir(&state.repo)
8045                .await
8046                .unwrap()
8047                .unwrap();
8048            std::fs::write(common.join("updater.contract"), "test-protocol-v1\n").unwrap();
8049            state.config.coordination = crate::config::test_coordination();
8050            if e2e {
8051                state.config.verify.e2e = vec!["true".into()];
8052                state.gate_repair_e2e = Some(crate::run::GateRepairE2e {
8053                    head: head.clone(),
8054                    passed: false,
8055                    outcomes: Vec::new(),
8056                });
8057            } else {
8058                state.config.verify.gate = vec!["true".into()];
8059                state.gate_ran = false;
8060            }
8061            let holder = crate::repo_lock::SeatLease::for_checkout(
8062                &state.repo,
8063                true,
8064                state.config.coordination.as_ref(),
8065            )
8066            .await
8067            .unwrap();
8068            assert!(holder.is_some());
8069            let cancelled = Arc::new(AtomicBool::new(false));
8070            let signal = cancelled.clone();
8071            let waiting = Arc::new(tokio::sync::Notify::new());
8072            let observed = waiting.clone();
8073            let wait = crate::repo_lock::WaitPolicy::new(Duration::from_secs(30), move || {
8074                observed.notify_one();
8075                signal.load(Ordering::SeqCst)
8076            });
8077            let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8078            let url = green_pr().url;
8079            let result = {
8080                let mut publishing =
8081                    Box::pin(wait.scope(publish_landing_head(&mut state, &head, &url, &forge)));
8082                // Git and fixture I/O precede local coordination. Start the
8083                // cancellation deadline only once the inherited wait policy
8084                // is polled, rather than timing those unrelated subprocesses.
8085                tokio::time::timeout(Duration::from_secs(30), async {
8086                    tokio::select! {
8087                        result = &mut publishing => panic!(
8088                            "verification must wait behind the held seat: {result:?}"
8089                        ),
8090                        () = waiting.notified() => {}
8091                    }
8092                })
8093                .await
8094                .expect("verification must reach the inherited coordination wait");
8095                cancelled.store(true, Ordering::SeqCst);
8096                tokio::time::timeout(Duration::from_secs(2), publishing).await
8097            };
8098            let error = result
8099                .expect("cancellation must release the verification waiter")
8100                .unwrap_err();
8101            assert!(error.is::<crate::repo_lock::ResourceBlocked>(), "{error:#}");
8102            assert!(error.to_string().contains("cancelled"), "{error:#}");
8103            assert!(forge.argvs().is_empty());
8104            assert_eq!(
8105                git::remote_head(&state.repo, "origin", "task-head")
8106                    .await
8107                    .unwrap(),
8108                Some(head.clone())
8109            );
8110            drop(holder);
8111            // Cancellation must release its reservation so another attempt can verify.
8112            assert_eq!(
8113                publish_landing_head(&mut state, &head, &url, &forge)
8114                    .await
8115                    .unwrap(),
8116                head
8117            );
8118        }
8119    }
8120
8121    #[tokio::test]
8122    async fn landing_resume_preserves_user_edits_after_an_interrupted_rebase() {
8123        for staged in [false, true] {
8124            let (_tmp, mut state, old) = landing_git_fixture().await;
8125            advance_landing_base(&state).await;
8126            interrupt_landing_rebase(&mut state).await;
8127            std::fs::write(state.repo.join("change"), "user edit").unwrap();
8128            if staged {
8129                git::git(&state.repo, &["add", "change"]).await.unwrap();
8130            }
8131            assert!(resume_observed_head(&mut state, &old).await.is_err());
8132            assert_eq!(
8133                std::fs::read_to_string(state.repo.join("change")).unwrap(),
8134                "user edit"
8135            );
8136            assert_eq!(
8137                git::remote_head(&state.repo, "origin", "task-head")
8138                    .await
8139                    .unwrap(),
8140                Some(old)
8141            );
8142        }
8143    }
8144
8145    #[tokio::test]
8146    async fn landing_resume_returns_to_approval_wait_and_closed_pr_skips_sync() {
8147        let (_tmp, mut state, head) = landing_git_fixture().await;
8148        let forge = Scripted::new(vec![seen(&head, Checks::Green, "CLEAN", false)], vec![]);
8149        let id = state.id.clone();
8150        let pr = land_with(&mut state, &green_pr().url, &forge)
8151            .await
8152            .unwrap();
8153        assert_eq!(pr.state, PrLifecycle::Open);
8154        assert_eq!(state.id, id);
8155        assert!(state.parked);
8156        assert_eq!(state.status, RunStatus::Landing);
8157        assert_eq!(state.land_approval.as_ref().unwrap().head, head);
8158        assert!(forge.argvs().is_empty());
8159
8160        state.land_sync_pending = true;
8161        std::fs::write(state.repo.join("dirty"), "do not touch").unwrap();
8162        let mut closed = seen("foreign", Checks::Green, "CLEAN", false);
8163        closed.pr.state = PrLifecycle::Closed;
8164        let forge = Scripted::new(vec![closed], vec![]);
8165        assert_eq!(
8166            land_with(&mut state, &green_pr().url, &forge)
8167                .await
8168                .unwrap()
8169                .state,
8170            PrLifecycle::Closed
8171        );
8172        assert_eq!(
8173            std::fs::read_to_string(state.repo.join("dirty")).unwrap(),
8174            "do not touch"
8175        );
8176        assert!(forge.argvs().is_empty());
8177    }
8178
8179    #[tokio::test]
8180    async fn landing_resume_does_not_publish_a_failed_rebased_gate() {
8181        let (_tmp, mut state, old) = landing_git_fixture().await;
8182        advance_landing_base(&state).await;
8183        state.config.verify.gate = vec!["exit 1".into()];
8184        assert!(resume_observed_head(&mut state, &old).await.is_err());
8185        assert_ne!(state.gate_head.as_deref(), Some(old.as_str()));
8186        assert!(!state.gate_allows_progress());
8187        assert_eq!(
8188            git::remote_head(&state.repo, "origin", "task-head")
8189                .await
8190                .unwrap(),
8191            Some(old)
8192        );
8193        assert!(state.land_sync_pending);
8194    }
8195
8196    #[tokio::test]
8197    async fn landing_publication_recovers_a_push_before_save_and_refuses_a_lease_conflict() {
8198        let (_tmp, mut state, old) = landing_git_fixture().await;
8199        advance_landing_base(&state).await;
8200        let pushed = resume_observed_head(&mut state, &old).await.unwrap();
8201        // Model a crash after the push but before saving its published SHA.
8202        state.pr_pushed_head = Some(old.clone());
8203        state.land_sync_pending = true;
8204        assert_eq!(
8205            resume_observed_head(&mut state, &pushed).await.unwrap(),
8206            pushed
8207        );
8208        // A writer races the observed PR snapshot: the explicit remote lease
8209        // must reject the push even though preflight saw our expected head.
8210        git::git(
8211            &state.repo,
8212            &["push", "--force", "origin", "main:refs/heads/task-head"],
8213        )
8214        .await
8215        .unwrap();
8216        assert!(
8217            publish_landing_head(
8218                &mut state,
8219                &pushed,
8220                &green_pr().url,
8221                &Scripted::new(vec![seen(&pushed, Checks::Green, "CLEAN", false)], vec![])
8222            )
8223            .await
8224            .unwrap_err()
8225            .to_string()
8226            .contains("publishing gated")
8227        );
8228        assert!(
8229            resume_observed_head(&mut state, "foreign-head")
8230                .await
8231                .is_err()
8232        );
8233    }
8234    #[tokio::test]
8235    async fn landing_publication_refuses_a_pr_closed_during_gate() {
8236        let (_tmp, mut state, head) = landing_git_fixture().await;
8237        let mut closed = seen(&head, Checks::Green, "CLEAN", false);
8238        closed.pr.state = PrLifecycle::Closed;
8239        let forge = Scripted::new(vec![closed], vec![]);
8240        assert!(
8241            publish_landing_head(&mut state, &head, &green_pr().url, &forge)
8242                .await
8243                .unwrap_err()
8244                .to_string()
8245                .contains("PR closed or merged")
8246        );
8247    }
8248
8249    /// A candidate selected by the uncontested tally.
8250    fn winning_candidate(summary: &str) -> Candidate {
8251        Candidate {
8252            index: 0,
8253            label: 'A',
8254            agent: "opus".to_owned(),
8255            branch: "magi/x/A".to_owned(),
8256            worktree: PathBuf::from("/wt/A"),
8257            summary: summary.to_owned(),
8258            stat: String::new(),
8259            files: 1,
8260            commits: 1,
8261            empty: false,
8262            failed: None,
8263            verified_noop: None,
8264            duration_ms: 0,
8265            folded: false,
8266        }
8267    }
8268
8269    fn uncontested_tally() -> Tally {
8270        Tally {
8271            first_choice: BTreeMap::from([('A', 1)]),
8272            borda: BTreeMap::new(),
8273            winner: 'A',
8274            rankings: 1,
8275            unanimous_initial: true,
8276            deliberated: false,
8277            changed_votes: 0,
8278            unanimous_final: true,
8279            tie_break: None,
8280            judges: 1,
8281            present: 1,
8282            quorum: 1,
8283            met_quorum: true,
8284            uncontested: None,
8285        }
8286    }
8287
8288    fn review_record(reviewer: usize, agent: &str, summary: &str) -> ReviewRecord {
8289        ReviewRecord {
8290            attempts: 0,
8291            reviewer,
8292            agent: agent.to_owned(),
8293            summary: summary.to_owned(),
8294            findings: Vec::new(),
8295            vote: None,
8296            failed: None,
8297            duration_ms: 0,
8298        }
8299    }
8300
8301    fn review_round(round: usize, reviews: Vec<ReviewRecord>) -> ReviewRound {
8302        let answered = reviews.len();
8303        ReviewRound {
8304            round,
8305            head: "abc1234".to_owned(),
8306            verified_head: None,
8307            verified_at: None,
8308            reviews,
8309            e2e: Vec::new(),
8310            verify_retried: false,
8311            e2e_deferred: false,
8312            e2e_defer_reason: None,
8313            fix: None,
8314            blocking: 0,
8315            answered,
8316            expected: answered,
8317            clean: true,
8318            progressed: false,
8319            vote_split: false,
8320            reconsideration: Vec::new(),
8321            verdict: None,
8322        }
8323    }
8324
8325    #[test]
8326    fn the_approval_panel_states_the_task_verbatim_in_either_language() {
8327        let en = panel();
8328        assert!(en.contains("Task"), "{en}");
8329        assert!(en.contains("add retries to the uploader"), "{en}");
8330
8331        let mut state = run_state();
8332        state.config.graph.language = "ja".to_owned();
8333        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8334        assert!(ja.contains("タスク"), "{ja}");
8335        assert!(
8336            ja.contains("add retries to the uploader"),
8337            "the task itself is not translated: {ja}"
8338        );
8339    }
8340
8341    #[test]
8342    fn the_approval_panel_omits_what_changed_and_review_verdict_with_no_data() {
8343        // `run_state()` has no candidates, no tally and no reviews - exactly
8344        // the shape a run has before anything has judged or reviewed it, and
8345        // the panel must not print an empty box for either.
8346        let html = panel();
8347        assert!(!html.contains("What changed"), "{html}");
8348        assert!(!html.contains("Review verdict"), "{html}");
8349    }
8350
8351    #[test]
8352    fn the_approval_panel_omits_what_changed_when_the_winners_summary_is_empty() {
8353        let mut state = run_state();
8354        state.candidates = vec![winning_candidate("")];
8355        state.tally = Some(uncontested_tally());
8356        let html = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8357        assert!(
8358            !html.contains("What changed"),
8359            "an empty summary must not render an empty box: {html}"
8360        );
8361    }
8362
8363    #[test]
8364    fn the_approval_panel_shows_the_winners_own_account_in_either_language() {
8365        let mut state = run_state();
8366        state.candidates = vec![winning_candidate(
8367            "Added a retry loop around the uploader PUT call.",
8368        )];
8369        state.tally = Some(uncontested_tally());
8370        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8371        assert!(en.contains("What changed"), "{en}");
8372        assert!(
8373            en.contains("Added a retry loop around the uploader PUT call."),
8374            "{en}"
8375        );
8376
8377        state.config.graph.language = "ja".to_owned();
8378        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8379        assert!(ja.contains("変更内容"), "{ja}");
8380        assert!(
8381            ja.contains("Added a retry loop around the uploader PUT call."),
8382            "{ja}"
8383        );
8384    }
8385
8386    #[test]
8387    fn the_approval_panel_shows_only_the_last_review_rounds_verdict() {
8388        let mut state = run_state();
8389        state.reviews = vec![
8390            review_round(
8391                1,
8392                vec![review_record(1, "alpha", "found a race, sent back")],
8393            ),
8394            review_round(2, vec![review_record(1, "alpha", "race is fixed, clean")]),
8395        ];
8396        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8397        assert!(en.contains("Review verdict"), "{en}");
8398        assert!(en.contains("race is fixed, clean"), "{en}");
8399        assert!(
8400            !en.contains("found a race, sent back"),
8401            "only the round that actually cleared the merge should show: {en}"
8402        );
8403
8404        state.config.graph.language = "ja".to_owned();
8405        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8406        assert!(ja.contains("レビューの結論"), "{ja}");
8407        assert!(ja.contains("レビュアー"), "{ja}");
8408        assert!(ja.contains("race is fixed, clean"), "{ja}");
8409    }
8410
8411    /// The `incomplete_review = "warn"` policy (see
8412    /// `graph::Runner::review_loop`) can push a `clean` round to
8413    /// `state.reviews` while one seat's own record still has `failed: Some`
8414    /// and an empty `summary` - a seat that never answered, not one that
8415    /// answered with nothing to say.
8416    fn unanswered_review_record(reviewer: usize, agent: &str, reason: &str) -> ReviewRecord {
8417        ReviewRecord {
8418            attempts: 0,
8419            reviewer,
8420            agent: agent.to_owned(),
8421            summary: String::new(),
8422            findings: Vec::new(),
8423            vote: None,
8424            failed: Some(reason.to_owned()),
8425            duration_ms: 0,
8426        }
8427    }
8428
8429    #[test]
8430    fn the_approval_panel_never_shows_an_unanswered_seat_as_a_blank_verdict() {
8431        let mut state = run_state();
8432        state.reviews = vec![review_round(
8433            1,
8434            vec![
8435                review_record(1, "alpha", "clean, nothing to add"),
8436                unanswered_review_record(2, "beta", "timed out"),
8437            ],
8438        )];
8439        let en = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8440        assert!(en.contains("clean, nothing to add"), "{en}");
8441        assert!(
8442            en.contains("produced no answer: timed out"),
8443            "a seat that never answered must say so, not render a blank box: {en}"
8444        );
8445        assert!(
8446            !en.contains("<div style=\"white-space:pre-wrap;font-size:13px\"></div>"),
8447            "no reviewer box may be left empty: {en}"
8448        );
8449
8450        state.config.graph.language = "ja".to_owned();
8451        let ja = approval_panel(&state, &green_pr(), NUMSTAT, "", &[], "feat: x");
8452        assert!(ja.contains("回答なし: timed out"), "{ja}");
8453    }
8454
8455    #[test]
8456    fn the_approval_panel_contains_nothing_the_frames_policy_would_block() {
8457        let html = panel();
8458        assert!(!html.contains("<script"), "no script survives the csp");
8459        assert!(!html.contains("<form"), "form-action is 'none'");
8460        let pr = green_pr();
8461        assert_eq!(
8462            html.matches("http").count(),
8463            html.matches(pr.url.as_str()).count(),
8464            "the only http url in the panel is the pull request's own link"
8465        );
8466        assert!(
8467            html.contains("target=\"_blank\""),
8468            "the PR link must open in a new tab so it escapes the iframe"
8469        );
8470        assert!(
8471            html.contains("rel=\"noopener noreferrer\""),
8472            "external links opening in new tabs must carry noopener noreferrer"
8473        );
8474    }
8475
8476    #[test]
8477    fn added_and_removed_diff_lines_are_distinguishable_without_colour() {
8478        let html = panel();
8479        assert!(
8480            html.contains(">+</span>"),
8481            "an added line carries a `+` in the gutter, not only a background"
8482        );
8483        assert!(
8484            html.contains(">-</span>"),
8485            "a removed line carries a `-` in the gutter, not only a background"
8486        );
8487        assert!(
8488            html.contains(">new line</span>"),
8489            "the marker is moved to the gutter, so the body is printed once without it"
8490        );
8491    }
8492
8493    #[test]
8494    fn a_diff_past_the_threshold_is_cut_with_an_honest_count() {
8495        let total = DIFF_MAX_LINES + 100;
8496        let diff: String = (0..total).map(|i| format!("+line {i}\n")).collect();
8497        let html = approval_panel(
8498            &run_state(),
8499            &green_pr(),
8500            NUMSTAT,
8501            &diff,
8502            &[],
8503            "feat: something long",
8504        );
8505        assert!(
8506            html.contains(&format!("100 of {total} diff lines omitted")),
8507            "the note must say exactly how much was cut"
8508        );
8509        assert!(html.contains(&format!("line {}", DIFF_MAX_LINES - 1)));
8510        assert!(
8511            !html.contains(&format!("line {DIFF_MAX_LINES}")),
8512            "nothing past the threshold is rendered"
8513        );
8514        assert!(
8515            html.contains("/repo/magi"),
8516            "the note says where the rest is"
8517        );
8518    }
8519
8520    #[test]
8521    fn a_path_with_html_metacharacters_is_escaped_rather_than_rendered() {
8522        let html = approval_panel(
8523            &run_state(),
8524            &green_pr(),
8525            "1\t2\tsrc/<b>&\"x\"'.rs",
8526            "",
8527            &[],
8528            "subject",
8529        );
8530        assert!(html.contains("src/&lt;b&gt;&amp;&quot;x&quot;&#39;.rs"));
8531        assert!(
8532            !html.contains("<b>"),
8533            "an agent-influenced path must never become markup"
8534        );
8535    }
8536
8537    #[tokio::test]
8538    async fn the_merge_lock_serialises_one_repository_but_never_a_different_one() {
8539        let a = std::path::PathBuf::from("/repo/a");
8540        let b = std::path::PathBuf::from("/repo/b");
8541
8542        let held = repo_merge_lock(&a).lock_owned().await;
8543
8544        // A second, concurrent land run against the *same* repository must
8545        // wait - `try_lock` fails while `held` is alive.
8546        assert!(
8547            repo_merge_lock(&a).try_lock().is_err(),
8548            "a second merge into the same repository must not proceed concurrently"
8549        );
8550
8551        // A run against a *different* repository must not be blocked by it -
8552        // this is what keeps a slow rebase or `gh pr merge` in one
8553        // repository from also stalling a land-approval resume in another.
8554        assert!(
8555            repo_merge_lock(&b).try_lock().is_ok(),
8556            "a different repository's merge lock must be independent"
8557        );
8558
8559        drop(held);
8560        assert!(
8561            repo_merge_lock(&a).try_lock().is_ok(),
8562            "the lock is released once the holder is done"
8563        );
8564    }
8565
8566    pub(crate) fn repair_state(major: bool, reject: bool) -> RunState {
8567        let mut state = landing_state();
8568        let mut rec = review_record(1, "alpha", "precise rejection reason");
8569        rec.vote = Some(if reject {
8570            crate::verdict::ReviewVote::Reject
8571        } else {
8572            crate::verdict::ReviewVote::Approve
8573        });
8574        if major {
8575            rec.findings = contested().findings;
8576        }
8577        let mut round = review_round(1, vec![rec]);
8578        round.clean = false;
8579        round.blocking = usize::from(major);
8580        state.reviews.push(round);
8581        state.config.graph.review_rounds = 1;
8582        state
8583    }
8584
8585    #[test]
8586    fn approval_policy_controls_are_independent_and_keep_all_reasons() {
8587        for every in [false, true] {
8588            for contested in [false, true] {
8589                for major in [false, true] {
8590                    for reject in [false, true] {
8591                        let mut state = repair_state(major, reject);
8592                        state.config.graph.land_approval = every;
8593                        state.config.graph.hold_contested_merge = contested;
8594                        let reasons = approval_reasons(&state);
8595                        assert_eq!(reasons.contains(&ApprovalReason::EveryMerge), every);
8596                        assert_eq!(
8597                            reasons.contains(&ApprovalReason::OpenBlockingFindings),
8598                            contested && major
8599                        );
8600                        assert_eq!(
8601                            reasons.contains(&ApprovalReason::FinalReject),
8602                            contested && reject
8603                        );
8604                        assert_eq!(
8605                            reasons.len(),
8606                            usize::from(every)
8607                                + usize::from(contested && major)
8608                                + usize::from(contested && reject)
8609                        );
8610                    }
8611                }
8612            }
8613        }
8614    }
8615
8616    #[test]
8617    fn approval_policy_and_rendering_follow_reconsideration_in_both_directions() {
8618        use crate::verdict::ReviewVote::{Approve, Reject};
8619        for (initial_reject, final_vote) in [(true, Approve), (false, Reject)] {
8620            let mut state = repair_state(false, initial_reject);
8621            state.reviews[0]
8622                .reconsideration
8623                .push(crate::run::ReviewRevoteRecord {
8624                    reviewer: 1,
8625                    agent: "alpha".into(),
8626                    vote: Some(final_vote),
8627                    reason: "changed after reading the panel <evidence>".into(),
8628                    failed: None,
8629                });
8630            assert_eq!(
8631                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8632                final_vote == Reject
8633            );
8634            for language in ["en", "ja"] {
8635                state.config.graph.language = language.into();
8636                let evidence = approval_evidence(&state);
8637                assert!(evidence.contains(&format!("→ {}", final_vote.label())));
8638                assert!(evidence.contains("changed after reading the panel <evidence>"));
8639                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8640                assert!(html.contains("changed after reading the panel &lt;evidence&gt;"));
8641                assert!(!html.contains("<evidence>"));
8642            }
8643            state.reviews[0].reconsideration[0].vote = None;
8644            state.reviews[0].reconsideration[0].failed = Some("timed out".into());
8645            assert_eq!(
8646                approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8647                initial_reject
8648            );
8649            assert!(approval_evidence(&state).contains("timed out"));
8650        }
8651    }
8652
8653    #[tokio::test]
8654    async fn approval_merge_paths_apply_controls_to_major_and_reject_independently() {
8655        for every in [false, true] {
8656            for contested in [false, true] {
8657                for (major, reject) in [(false, false), (false, true), (true, false), (true, true)]
8658                {
8659                    let mut state = repair_state(major, reject);
8660                    state.config.graph.land_approval = every;
8661                    state.config.graph.hold_contested_merge = contested;
8662                    let forge = Scripted::new(
8663                        vec![seen("abc", Checks::Green, "CLEAN", false)],
8664                        vec![(true, "")],
8665                    );
8666                    land_with(&mut state, URL, &forge).await.unwrap();
8667                    let expected = every || (contested && (major || reject));
8668                    assert_eq!(state.land_approval.is_some(), expected);
8669                    assert_eq!(forge.calls().contains(&"merge"), !expected);
8670                    if expected {
8671                        assert!(state.parked);
8672                    }
8673                }
8674            }
8675        }
8676    }
8677
8678    #[tokio::test]
8679    async fn reviewed_branch_hold_is_read_before_landing_decision() {
8680        for (base, control) in [
8681            (false, "land_approval"),
8682            (true, "land_approval"),
8683            (false, "hold_contested_merge"),
8684        ] {
8685            let (_tmp, mut state, _) = landing_git_fixture().await;
8686            state.config.graph.land_approval = base;
8687            state.config.graph.hold_contested_merge = false;
8688            state.reviewed_commits = Some(Vec::new());
8689            if control == "hold_contested_merge" {
8690                state.reviews = repair_state(false, true).reviews;
8691            }
8692            std::fs::write(
8693                state.repo.join("magi.toml"),
8694                format!("[graph]\n{control} = {}\n", !base),
8695            )
8696            .unwrap();
8697            git::git(&state.repo, &["add", "magi.toml"]).await.unwrap();
8698            git::git(&state.repo, &["commit", "-m", "approval policy"])
8699                .await
8700                .unwrap();
8701            let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8702            state.land_sync_pending = false;
8703            state.pr_pushed_head = Some(head.clone());
8704            state.gate_head = Some(head.clone());
8705            let forge = Scripted::new(
8706                vec![seen(&head, Checks::Green, "CLEAN", false)],
8707                vec![(true, "")],
8708            );
8709            land_with(&mut state, URL, &forge).await.unwrap();
8710            assert!(
8711                state.land_approval.is_some(),
8712                "base={base}, control={control}"
8713            );
8714            assert!(!forge.calls().contains(&"merge"));
8715            if !base {
8716                let question = ask::Questions::open()
8717                    .get(&state.land_approval.as_ref().unwrap().question)
8718                    .unwrap();
8719                assert!(question.detail.contains(&format!(
8720                    "{control}=true from the reviewed branch's magi.toml"
8721                )));
8722            }
8723        }
8724    }
8725
8726    #[tokio::test]
8727    async fn reviewed_branch_hold_asks_and_survives_removal_and_resume() {
8728        for file in ["magi.toml", ".magi/config.toml"] {
8729            for control in crate::config::OwnerHold::ALL {
8730                let (_tmp, mut state, _) = landing_git_fixture().await;
8731                state.config.graph.land_approval = false;
8732                state.config.graph.hold_contested_merge = false;
8733                state.reviewed_commits = Some(Vec::new());
8734                if control == crate::config::OwnerHold::HoldContestedMerge {
8735                    state.reviews = repair_state(false, true).reviews;
8736                }
8737                let config = state.repo.join(file);
8738                std::fs::create_dir_all(config.parent().unwrap()).unwrap();
8739                std::fs::write(
8740                    &config,
8741                    format!(
8742                        "[graph]\n{} = true\n[verify]\ngate = ['untrusted']\n",
8743                        control.key()
8744                    ),
8745                )
8746                .unwrap();
8747                git::git(&state.repo, &["add", file]).await.unwrap();
8748                git::git(&state.repo, &["commit", "-m", "require approval"])
8749                    .await
8750                    .unwrap();
8751                let head = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8752                let gate = state.config.verify.gate.clone();
8753                state.observe_branch_owner_holds(&head).await;
8754                assert_eq!(state.config.verify.gate, gate);
8755                assert!(state.owner_hold(control));
8756                let holds = state.branch_owner_holds.clone();
8757                git::git(&state.repo, &["rm", file]).await.unwrap();
8758                git::git(&state.repo, &["commit", "-m", "remove config"])
8759                    .await
8760                    .unwrap();
8761                let later = git::rev_parse(&state.repo, "HEAD").await.unwrap();
8762                state.save().unwrap();
8763                state = RunState::load(&state.id).unwrap();
8764                state.observe_branch_owner_holds(&later).await;
8765                assert_eq!(state.branch_owner_holds, holds);
8766                assert_eq!(state.branch_owner_holds[0].head, head);
8767                state.land_sync_pending = false;
8768                state.pr_pushed_head = Some(later.clone());
8769                state.gate_head = Some(later.clone());
8770                let forge = Scripted::new(
8771                    vec![seen(&later, Checks::Green, "CLEAN", false)],
8772                    vec![(true, "")],
8773                );
8774                land_with(&mut state, URL, &forge).await.unwrap();
8775                assert!(!forge.calls().contains(&"merge"));
8776                assert!(state.parked);
8777                let question = ask::Questions::open()
8778                    .get(&state.land_approval.as_ref().unwrap().question)
8779                    .unwrap();
8780                assert!(question.detail.contains("Approval required because:"));
8781                assert!(question.detail.contains(&format!(
8782                    "{}=true from the reviewed branch's {file}",
8783                    control.key()
8784                )));
8785                assert_eq!(state.branch_owner_holds, holds);
8786                state.save().unwrap();
8787                let resumed = RunState::load(&state.id).unwrap();
8788                assert!(resumed.owner_hold(control));
8789                assert_eq!(resumed.branch_owner_holds, holds);
8790                assert_eq!(
8791                    resumed.land_approval.unwrap().question,
8792                    state.land_approval.unwrap().question
8793                );
8794            }
8795        }
8796    }
8797
8798    #[tokio::test]
8799    async fn approval_snapshot_exposes_minor_findings_and_survives_disabled_controls() {
8800        let mut state = repair_state(false, true);
8801        state.reviews[0].clean = true;
8802        let mut finding = contested().findings.remove(0);
8803        finding.severity = crate::verdict::Severity::Minor;
8804        state.reviews[0].reviews[0].findings.push(finding);
8805        assert!(state.open_findings().is_empty());
8806        let mut pr = green_pr();
8807        pr.review_comments.clear();
8808        for language in ["en", "ja"] {
8809            state.config.graph.language = language.into();
8810            let html = approval_panel(&state, &pr, "", "", &[], "subject");
8811            assert!(html.contains("[R3-1-1] minor"));
8812            assert!(html.contains(if language == "ja" {
8813                "内部指摘の解決を意味するものではありません"
8814            } else {
8815                "This does not mean internal findings are resolved"
8816            }));
8817            assert!(
8818                html.find(if language == "ja" {
8819                    "承認が必要な理由:"
8820                } else {
8821                    "Approval required because:"
8822                })
8823                .unwrap()
8824                    < html.find("[R3-1-1]").unwrap()
8825            );
8826        }
8827        state.config.graph.language = "en".into();
8828        let observed = seen("abc", Checks::Green, "CLEAN", false);
8829        assert_eq!(
8830            approval_gate(&mut state, &observed.pr, "subject", None, "abc")
8831                .await
8832                .unwrap(),
8833            ApprovalGate::Pending
8834        );
8835        let id = state.land_approval.as_ref().unwrap().question.clone();
8836        let q = ask::Questions::open().get(&id).unwrap();
8837        assert!(q.detail.starts_with("Approval required because:"));
8838        assert!(q.detail.contains("final reviewer reject"));
8839        assert!(q.detail.contains("[R3-1-1] minor"));
8840        assert!(
8841            q.detail
8842                .contains("No adoption or resolution evidence recorded")
8843        );
8844        state.config.graph.land_approval = false;
8845        state.config.graph.hold_contested_merge = false;
8846        state.reviews.clear();
8847        assert_eq!(approval_reasons(&state), [ApprovalReason::ExistingDecision]);
8848        assert!(deputy_brief(&q, Some(&state)).contains("final reviewer reject"));
8849        assert!(deputy_brief(&q, None).contains("[R3-1-1] minor"));
8850        let forge = Scripted::new(vec![observed], vec![]);
8851        land_with(&mut state, URL, &forge).await.unwrap();
8852        assert!(!forge.calls().contains(&"merge"));
8853        assert_eq!(state.land_approval.as_ref().unwrap().question, id);
8854        assert_eq!(ask::Questions::open().get(&id).unwrap().detail, q.detail);
8855    }
8856
8857    #[tokio::test]
8858    async fn approval_evidence_keeps_all_findings_and_bounds_unicode_prose() {
8859        let mut state = repair_state(false, true);
8860        state.config.graph.land_approval = false;
8861        let long_reason = format!("{}REASON_TAIL", "拒否🦀".repeat(200));
8862        let long_detail = format!("{}DETAIL_TAIL", "詳細🦀".repeat(200));
8863        let long_title = format!("{}TITLE_TAIL", "題🦀".repeat(100));
8864        state.reviews[0].reviews[0].summary = long_reason.clone();
8865        for i in 1..=10 {
8866            let mut finding = contested().findings.remove(0);
8867            finding.id = format!("R1-1-{i}");
8868            finding.severity = if i == 9 {
8869                crate::verdict::Severity::Major
8870            } else {
8871                crate::verdict::Severity::Minor
8872            };
8873            finding.title = long_title.clone();
8874            finding.detail = long_detail.clone();
8875            state.reviews[0].reviews[0].findings.push(finding);
8876        }
8877        state.reviews[0].fix = Some(crate::run::FixRecord {
8878            agent: "fixer".into(),
8879            addressed: vec!["R1-1-9".into()],
8880            rejected: vec![crate::verdict::Rejection {
8881                id: "R1-1-10".into(),
8882                why: long_reason.clone(),
8883            }],
8884            notes: long_detail.clone(),
8885            committed: false,
8886            failed: None,
8887            duration_ms: 0,
8888            continuation: None,
8889        });
8890        assert_eq!(
8891            approval_reasons(&state),
8892            [
8893                ApprovalReason::OpenBlockingFindings,
8894                ApprovalReason::FinalReject
8895            ]
8896        );
8897        let excerpt = approval_excerpt(&long_reason, 300);
8898        assert_eq!(excerpt.chars().count(), 300);
8899        assert!(excerpt.ends_with('…'));
8900        let boundary = "拒".repeat(300);
8901        assert_eq!(approval_excerpt(&boundary, 300), boundary);
8902        for language in ["en", "ja"] {
8903            state.config.graph.language = language.into();
8904            for revote in [
8905                None,
8906                Some(crate::verdict::ReviewVote::Reject),
8907                Some(crate::verdict::ReviewVote::Approve),
8908            ] {
8909                state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8910                state.reviews[0].reconsideration.clear();
8911                if let Some(vote) = revote {
8912                    state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Approve);
8913                    state.reviews[0]
8914                        .reconsideration
8915                        .push(crate::run::ReviewRevoteRecord {
8916                            reviewer: 1,
8917                            agent: "alpha".into(),
8918                            vote: Some(vote),
8919                            reason: long_reason.clone(),
8920                            failed: None,
8921                        });
8922                }
8923                let evidence = approval_evidence(&state);
8924                let final_vote = revote.unwrap_or(crate::verdict::ReviewVote::Reject);
8925                assert!(evidence.contains(&format!("#1 (alpha): {}", final_vote.label())));
8926                assert!(approval_reasons(&state).contains(&ApprovalReason::OpenBlockingFindings));
8927                assert_eq!(
8928                    approval_reasons(&state).contains(&ApprovalReason::FinalReject),
8929                    final_vote == crate::verdict::ReviewVote::Reject
8930                );
8931                assert!(evidence.contains(&excerpt));
8932                assert!(evidence.contains("[R1-1-8] minor"));
8933                assert!(evidence.contains("[R1-1-9] major"));
8934                assert!(evidence.contains("[R1-1-10] minor"));
8935                for tail in ["REASON_TAIL", "DETAIL_TAIL", "TITLE_TAIL"] {
8936                    assert!(!evidence.contains(tail));
8937                }
8938                assert!(evidence.contains(if language == "ja" {
8939                    "採用申告(解決未確認)"
8940                } else {
8941                    "claimed addressed (resolution unconfirmed)"
8942                }));
8943                assert!(evidence.contains(if language == "ja" {
8944                    "不採用理由"
8945                } else {
8946                    "declined"
8947                }));
8948                let major_evidence = evidence
8949                    .split("[R1-1-9] major")
8950                    .nth(1)
8951                    .unwrap()
8952                    .split("[R1-1-10]")
8953                    .next()
8954                    .unwrap();
8955                assert!(major_evidence.contains(if language == "ja" {
8956                    "採用申告(解決未確認)"
8957                } else {
8958                    "claimed addressed (resolution unconfirmed)"
8959                }));
8960                let last_evidence = evidence.split("[R1-1-10] minor").nth(1).unwrap();
8961                assert!(last_evidence.contains(if language == "ja" {
8962                    "不採用理由"
8963                } else {
8964                    "declined"
8965                }));
8966                let html = approval_panel(&state, &green_pr(), "", "", &[], "subject");
8967                assert!(html.contains(&esc(&evidence)));
8968                assert_eq!(
8969                    approval_gate(&mut state, &green_pr(), "subject", None, "abc")
8970                        .await
8971                        .unwrap(),
8972                    ApprovalGate::Pending
8973                );
8974                let id = &state.land_approval.take().unwrap().question;
8975                let q = ask::Questions::open().get(id).unwrap();
8976                assert!(q.detail.starts_with(&evidence));
8977            }
8978            state.reviews[0].reviews[0].vote = Some(crate::verdict::ReviewVote::Reject);
8979        }
8980    }
8981
8982    #[test]
8983    fn merge_repair_choice_covers_major_only_and_reject_only_without_contested_and() {
8984        for (major, reject) in [(true, false), (false, true), (true, true)] {
8985            let state = repair_state(major, reject);
8986            assert!(repair_available(&state));
8987            assert_eq!(&approval_choices(&state)[..3], [APPROVE, HOLD, REPAIR]);
8988        }
8989        assert!(!repair_available(&repair_state(false, false)));
8990        let mut clean = repair_state(true, false);
8991        clean.reviews[0].clean = true;
8992        assert_eq!(&approval_choices(&clean)[..3], [APPROVE, HOLD, REPAIR]);
8993        assert_eq!(approval(Some(REPAIR)), Approval::Repair);
8994    }
8995
8996    #[tokio::test]
8997    async fn merge_repair_upgrades_legacy_questions_and_snapshots_exact_evidence_once() {
8998        let mut state = repair_state(true, false);
8999        let pr = green_pr();
9000        approval_gate(&mut state, &pr, "feat: x", None, "abc")
9001            .await
9002            .unwrap();
9003        let store = ask::Questions::open();
9004        let id = state.land_approval.as_ref().unwrap().question.clone();
9005        // Simulate an unanswered two-choice question created by an older build.
9006        store
9007            .update(&id, |q| {
9008                q.choices = vec![APPROVE.into(), HOLD.into()];
9009                Ok(())
9010            })
9011            .unwrap();
9012        let upgraded = store.get(&id).unwrap();
9013        assert_eq!(upgraded.choices, approval_choices(&state));
9014        assert!(!upgraded.actions.contains_key(HOLD));
9015        assert_eq!(store.get(&id).unwrap().choices, upgraded.choices);
9016        assert!(
9017            store
9018                .list()
9019                .iter()
9020                .any(|q| q.id == id && q.choices == upgraded.choices)
9021        );
9022        assert!(
9023            crate::prompt::chat_consult(&upgraded).contains("--reply 'fix findings and re-review'")
9024        );
9025        assert!(deputy_brief(&upgraded, Some(&state)).contains("never approves a merge"));
9026        store
9027            .update(&id, |q| q.answer(ask::Answer::Choice(REPAIR.into())))
9028            .unwrap();
9029        // An earlier build may have recorded the repair answer and stopped
9030        // before any driver consumed it. Reconcile that durable decision too.
9031        store
9032            .update(&id, |q| {
9033                q.actions.clear();
9034                Ok(())
9035            })
9036            .unwrap();
9037        assert_eq!(
9038            store.get(&id).unwrap().chosen_action(),
9039            Some(&ask::ChoiceAction::Resume {
9040                run: state.id.clone()
9041            })
9042        );
9043        let binding = state.land_approval.clone();
9044        assert_eq!(
9045            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9046                .await
9047                .unwrap(),
9048            ApprovalGate::Repair
9049        );
9050        assert!(state.land_approval.is_none());
9051        assert_eq!(state.merge_repairs.len(), 1);
9052        let request = &state.merge_repairs[0];
9053        assert_eq!(request.question, id);
9054        assert_eq!(request.head, "abc");
9055        assert_eq!(request.pr, pr.url);
9056        assert_eq!(
9057            request.reviews[0].reviews[0].summary,
9058            "precise rejection reason"
9059        );
9060        assert_eq!(request.reviews[0].reviews[0].findings[0].id, "R3-1-1");
9061        assert_eq!(
9062            request.reviews[0].reviews[0].findings[0].detail,
9063            state.reviews[0].reviews[0].findings[0].detail
9064        );
9065        // A repeated consumption of the same answer never dispatches another repair.
9066        state.land_approval = binding;
9067        assert_eq!(
9068            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9069                .await
9070                .unwrap(),
9071            ApprovalGate::Repair
9072        );
9073        assert_eq!(state.merge_repairs.len(), 1);
9074        state.land_approval = None;
9075        state.config.graph.land_approval = false;
9076        state.config.graph.hold_contested_merge = false;
9077        state.reviews.clear();
9078        assert_eq!(approval_reasons(&state), [ApprovalReason::AfterRepair]);
9079        assert_eq!(
9080            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9081                .await
9082                .unwrap(),
9083            ApprovalGate::Pending
9084        );
9085        let fresh_id = &state.land_approval.as_ref().unwrap().question;
9086        assert_ne!(fresh_id, &id);
9087        let fresh = store.get(fresh_id).unwrap();
9088        assert!(
9089            fresh
9090                .detail
9091                .contains("fresh owner approval after merge repair")
9092        );
9093        assert!(fresh.detail.contains("No review recorded"));
9094        assert!(!fresh.detail.contains("precise rejection reason"));
9095    }
9096
9097    #[tokio::test]
9098    async fn merge_repair_answer_never_merges_and_hold_never_requests_repair() {
9099        for choice in [REPAIR, HOLD] {
9100            let mut state = repair_state(false, true);
9101            let observed = seen("abc", Checks::Green, "CLEAN", false);
9102            approval_gate(&mut state, &observed.pr, "feat: x", None, "abc")
9103                .await
9104                .unwrap();
9105            let id = state.land_approval.as_ref().unwrap().question.clone();
9106            ask::Questions::open()
9107                .update(&id, |q| q.answer(ask::Answer::Choice(choice.into())))
9108                .unwrap();
9109            let forge = Scripted::new(vec![observed], vec![(true, "")]);
9110            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
9111                .await
9112                .unwrap();
9113            assert!(
9114                forge
9115                    .argvs()
9116                    .iter()
9117                    .all(|a| a.iter().any(|s| s == "--disable-auto"))
9118            );
9119            assert!(!forge.calls().contains(&"fix"));
9120            assert_eq!(state.merge_repairs.len(), usize::from(choice == REPAIR));
9121            assert_ne!(state.status, RunStatus::Merged);
9122        }
9123    }
9124
9125    #[test]
9126    fn only_the_merge_choice_merges_and_silence_holds() {
9127        let table = [
9128            (None, Approval::Hold),
9129            (Some("merge"), Approval::Merge),
9130            (Some(" merge\n"), Approval::Merge),
9131            (Some("hold"), Approval::Hold),
9132            (Some(""), Approval::Hold),
9133            (Some("yes"), Approval::Hold),
9134        ];
9135        for (answer, want) in table {
9136            assert_eq!(
9137                approval(answer),
9138                want,
9139                "answer {answer:?} must resolve to {want:?}"
9140            );
9141        }
9142    }
9143
9144    #[tokio::test]
9145    async fn a_first_visit_to_the_merge_gate_files_a_question_and_returns_pending_at_once() {
9146        let mut state = landing_state();
9147        state.config.graph.land_approval = true;
9148        let pr = green_pr();
9149
9150        let gate = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9151            .await
9152            .unwrap();
9153        assert_eq!(gate, ApprovalGate::Pending, "nobody has answered yet");
9154        assert!(
9155            !state.parked,
9156            "approval_gate itself never sets `parked`; only its caller does"
9157        );
9158
9159        let store = ask::Questions::open();
9160        let filed: Vec<_> = store
9161            .list()
9162            .into_iter()
9163            .filter(|q| q.run == state.id)
9164            .collect();
9165        assert_eq!(filed.len(), 1, "exactly one question is filed");
9166        assert_eq!(filed[0].node, APPROVAL_NODE);
9167        assert_eq!(filed[0].choices, approval_choices(&state));
9168        assert!(filed[0].status.open());
9169
9170        // A second visit - standing in for a resumed run whose slot the
9171        // daemon handed to something else while nobody had answered - must
9172        // find the same question rather than filing a second one.
9173        let again = approval_gate(&mut state, &pr, "feat: x", None, "abc")
9174            .await
9175            .unwrap();
9176        assert_eq!(again, ApprovalGate::Pending);
9177        let still_one = store
9178            .list()
9179            .into_iter()
9180            .filter(|q| q.run == state.id)
9181            .count();
9182        assert_eq!(
9183            still_one, 1,
9184            "asking twice must not double-file the question"
9185        );
9186    }
9187
9188    #[tokio::test]
9189    async fn approving_the_existing_question_is_read_back_as_approved() {
9190        crate::run::pin_test_home();
9191        let mut state = run_state();
9192        state.config.graph.land_approval = true;
9193        let pr = green_pr();
9194        assert_eq!(
9195            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9196                .await
9197                .unwrap(),
9198            ApprovalGate::Pending
9199        );
9200
9201        let store = ask::Questions::open();
9202        let mut q = store
9203            .list()
9204            .into_iter()
9205            .find(|q| q.run == state.id)
9206            .expect("filed above");
9207        let talks = crate::talk::Talks::open();
9208        let cfg = crate::config::Config {
9209            agents: vec![crate::config::AgentSpec {
9210                id: "mock".into(),
9211                kind: crate::config::AgentKind::Command,
9212                model: None,
9213                command: vec!["true".into()],
9214                extra_args: Vec::new(),
9215                env: Default::default(),
9216                prompt_delivery: None,
9217                max_turn_secs: None,
9218            }],
9219            ..crate::config::Config::default()
9220        };
9221        let mut talk = crate::talk::begin(&talks, &cfg, state.repo.clone(), Some("mock")).unwrap();
9222        crate::consult::begin(&store, &talks, &q, &talk).unwrap();
9223        crate::talk::drain(&mut talk, &talks).unwrap();
9224        assert_eq!(
9225            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9226                .await
9227                .unwrap(),
9228            ApprovalGate::Pending,
9229            "consultation cannot approve or hold the merge"
9230        );
9231        q = store.get(&q.id).unwrap();
9232        assert!(q.answer.is_none());
9233        // The owner's merge answer (from the chat or the card) is the approval.
9234        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9235        store.put(&mut q).unwrap();
9236
9237        assert_eq!(
9238            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9239                .await
9240                .unwrap(),
9241            ApprovalGate::Approved
9242        );
9243    }
9244
9245    #[tokio::test]
9246    async fn legacy_hold_actions_are_removed_on_get_and_list_before_answering() {
9247        crate::run::pin_test_home();
9248        let store = ask::Questions::open();
9249        for via_list in [false, true] {
9250            let mut state = repair_state(false, true);
9251            let pr = green_pr();
9252            approval_gate(&mut state, &pr, "feat: x", None, "abc")
9253                .await
9254                .unwrap();
9255            let id = state.land_approval.as_ref().unwrap().question.clone();
9256            store
9257                .update(&id, |q| {
9258                    q.actions.insert(
9259                        HOLD.to_owned(),
9260                        ask::ChoiceAction::Resume {
9261                            run: state.id.clone(),
9262                        },
9263                    );
9264                    Ok(())
9265                })
9266                .unwrap();
9267            let q = if via_list {
9268                store.list().into_iter().find(|q| q.id == id).unwrap()
9269            } else {
9270                store.get(&id).unwrap()
9271            };
9272            assert!(!q.actions.contains_key(HOLD));
9273            store
9274                .update(&id, |q| {
9275                    // Check the persisted migration, without another read upgrade.
9276                    assert!(!q.actions.contains_key(HOLD));
9277                    assert!(!upgrade_repair_choice(q, &state));
9278                    q.answer(ask::Answer::Choice(HOLD.to_owned()))
9279                })
9280                .unwrap();
9281            assert!(store.get(&id).unwrap().chosen_action().is_none());
9282            assert_eq!(
9283                approval_gate(&mut state, &pr, "feat: x", None, "abc")
9284                    .await
9285                    .unwrap(),
9286                ApprovalGate::Held
9287            );
9288        }
9289    }
9290
9291    #[tokio::test]
9292    async fn explicit_hold_stays_held_but_expired_approval_is_reasked() {
9293        crate::run::pin_test_home();
9294        let store = ask::Questions::open();
9295
9296        let mut held_state = run_state();
9297        held_state.config.graph.land_approval = true;
9298        let pr = green_pr();
9299        approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9300            .await
9301            .unwrap();
9302        let mut q = store
9303            .list()
9304            .into_iter()
9305            .find(|q| q.run == held_state.id)
9306            .expect("filed above");
9307        q.answer(ask::Answer::Choice(HOLD.to_owned())).unwrap();
9308        store.put(&mut q).unwrap();
9309        assert_eq!(
9310            approval_gate(&mut held_state, &pr, "feat: x", None, "abc")
9311                .await
9312                .unwrap(),
9313            ApprovalGate::Held
9314        );
9315
9316        let mut abandoned_state = run_state();
9317        abandoned_state.config.graph.land_approval = true;
9318        approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9319            .await
9320            .unwrap();
9321        let mut q = store
9322            .list()
9323            .into_iter()
9324            .find(|q| q.run == abandoned_state.id)
9325            .expect("filed above");
9326        q.abandon("no answer within the timeout");
9327        store.put(&mut q).unwrap();
9328        assert_eq!(
9329            approval_gate(&mut abandoned_state, &pr, "feat: x", None, "abc")
9330                .await
9331                .unwrap(),
9332            ApprovalGate::Pending,
9333            "an expired approval must ask again without merging"
9334        );
9335        assert_eq!(
9336            store.get(&q.id).unwrap().status,
9337            ask::QuestionStatus::Abandoned
9338        );
9339        assert_ne!(
9340            abandoned_state.land_approval.as_ref().unwrap().question,
9341            q.id
9342        );
9343    }
9344
9345    fn contested() -> ContestedHandoff {
9346        let finding = |id: &str, n: u32| crate::verdict::Finding {
9347            id: id.to_owned(),
9348            severity: crate::verdict::Severity::Major,
9349            file: Some("src/a.rs".to_owned()),
9350            line: Some(n),
9351            title: format!("problem {id}"),
9352            detail: String::new(),
9353        };
9354        ContestedHandoff {
9355            findings: (1..=7).map(|n| finding(&format!("R3-1-{n}"), n)).collect(),
9356            rejecters: vec![(1, "alpha".to_owned())],
9357        }
9358    }
9359
9360    #[test]
9361    fn the_contested_record_is_asked_about_unless_the_switch_is_off() {
9362        let mut state = run_state();
9363        assert!(contested_to_ask(&state).is_none(), "nothing recorded");
9364        state.contested_handoff = Some(contested());
9365        assert!(contested_to_ask(&state).is_some());
9366        state.config.graph.hold_contested_merge = false;
9367        assert!(
9368            contested_to_ask(&state).is_none(),
9369            "the switch restores today"
9370        );
9371    }
9372
9373    #[test]
9374    fn the_deputy_brief_carries_the_pr_the_findings_and_names_what_is_missing() {
9375        let q = ask::Question::new(
9376            "run-1".to_owned(),
9377            APPROVAL_NODE.to_owned(),
9378            "land".to_owned(),
9379            "Merge?".to_owned(),
9380            String::new(),
9381            vec![APPROVE.to_owned(), HOLD.to_owned()],
9382        );
9383        let none = deputy_brief(&q, None);
9384        assert!(none.contains("could not be read"), "{none}");
9385        assert!(none.contains("Silence is a hold"), "{none}");
9386
9387        let mut state = run_state();
9388        state.pr = Some(crate::run::PrRecord {
9389            url: "https://example.test/pull/7".to_owned(),
9390            number: 7,
9391            state: "open".to_owned(),
9392            checks: "green".to_owned(),
9393            round: 0,
9394            rounds: 3,
9395            red_at_merge: Vec::new(),
9396        });
9397        state.contested_handoff = Some(contested());
9398        let b = deputy_brief(&q, Some(&state));
9399        assert!(b.contains("https://example.test/pull/7"), "{b}");
9400        assert!(b.contains("not recorded in this legacy question"), "{b}");
9401        assert!(!b.contains("This question was filed although"));
9402        state.contested_handoff = None;
9403        assert!(deputy_brief(&q, Some(&state)).contains("not recorded in this legacy question"));
9404    }
9405
9406    #[test]
9407    fn the_contested_question_names_the_pr_the_findings_and_the_rejecter() {
9408        for lang in ["en", "ja"] {
9409            let mut cfg = crate::config::Config::default();
9410            cfg.graph.language = lang.to_owned();
9411            let w = words(&cfg.graph.language);
9412            let text = w.approval_detail(
9413                "https://github.com/yukimemi/magi/pull/42",
9414                "main",
9415                "feat: x",
9416                Some(&contested()),
9417            );
9418            assert!(text.contains("pull/42"), "{text}");
9419            assert!(
9420                text.contains("R3-1-1 major src/a.rs:1: problem R3-1-1"),
9421                "{text}"
9422            );
9423            assert!(text.contains("R3-1-5"), "{text}");
9424            assert!(!text.contains("R3-1-6"), "the list is capped: {text}");
9425            assert!(text.contains("2"), "the rest are counted: {text}");
9426            assert!(text.contains("#1 (alpha)"), "{text}");
9427        }
9428        let plain = words("en").approval_detail("u", "main", "s", None);
9429        assert!(!plain.contains("reject"), "{plain}");
9430    }
9431
9432    #[tokio::test]
9433    async fn a_contested_question_is_filed_once_and_a_resume_finds_the_same_one() {
9434        crate::run::pin_test_home();
9435        let mut state = run_state();
9436        state.config.graph.land_approval = false;
9437        state.contested_handoff = Some(contested());
9438        let pr = green_pr();
9439        let c = contested_to_ask(&state);
9440        assert_eq!(
9441            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9442                .await
9443                .unwrap(),
9444            ApprovalGate::Pending,
9445            "silence is a hold"
9446        );
9447        let store = ask::Questions::open();
9448        let filed: Vec<_> = store
9449            .list()
9450            .into_iter()
9451            .filter(|q| q.run == state.id)
9452            .collect();
9453        assert_eq!(filed.len(), 1);
9454        assert!(filed[0].detail.contains("R3-1-1"), "{}", filed[0].detail);
9455
9456        assert_eq!(
9457            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9458                .await
9459                .unwrap(),
9460            ApprovalGate::Pending
9461        );
9462        let mut q = store
9463            .list()
9464            .into_iter()
9465            .find(|q| q.run == state.id)
9466            .unwrap();
9467        assert_eq!(q.id, filed[0].id, "the same question after a resume");
9468        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
9469        store.put(&mut q).unwrap();
9470        assert_eq!(
9471            approval_gate(&mut state, &pr, "feat: x", c.as_ref(), "abc")
9472                .await
9473                .unwrap(),
9474            ApprovalGate::Approved
9475        );
9476    }
9477
9478    #[test]
9479    fn the_diffstat_table_is_ordered_by_churn_with_binaries_last() {
9480        let rows = parse_numstat(NUMSTAT);
9481        assert_eq!(
9482            rows.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
9483            ["src/web.rs", "src/land.rs", "assets/logo.png"]
9484        );
9485        assert_eq!(rows[2].added, None, "a binary file has no line counts");
9486    }
9487    #[test]
9488    fn the_approval_speaks_the_language_the_repository_is_configured_for() {
9489        // Reported from a real run: the merge question arrived in English on a
9490        // repository with `language = "ja"`. magi's own strings have to follow
9491        // that setting too - "it is a literal in Rust" is not an answer.
9492        let mut state = run_state();
9493        state.config.graph.language = "ja".to_owned();
9494        let pr = green_pr();
9495        let commits = ["c1".to_owned()];
9496
9497        let ja = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9498        assert!(ja.contains("lang=\"ja\""), "the document must declare it");
9499        assert!(ja.contains("squash されるコミット"), "{ja}");
9500        assert!(ja.contains("レビューコメント"), "{ja}");
9501        assert!(ja.contains("差分"), "{ja}");
9502        assert!(
9503            !ja.contains("Commits being squashed"),
9504            "no English left over"
9505        );
9506
9507        let w = words("ja");
9508        assert!(w.approval_summary(17, "feat: x").contains("マージ"));
9509        assert!(
9510            w.approval_detail("http://x/1", "main", "feat: x", None)
9511                .contains("パネル")
9512        );
9513
9514        // The evidence itself is language-neutral and must survive either way.
9515        assert!(ja.contains("src/a.rs"), "the diffstat is not prose");
9516        assert!(ja.contains("feat: x"), "nor is the merge subject");
9517
9518        // English stays the default, and a language magi cannot check falls
9519        // back to it rather than shipping a guess.
9520        state.config.graph.language = "en".to_owned();
9521        let en = approval_panel(&state, &pr, "3\t1\tsrc/a.rs", "+ x", &commits, "feat: x");
9522        assert!(en.contains("Commits being squashed"), "{en}");
9523        assert_eq!(words("Klingon").html_lang, "en");
9524    }
9525
9526    /// A `gh pr list` result naming exactly one pull request whose base and
9527    /// merge time both fit the run is exactly the case
9528    /// [`find_external_merge`] exists to act on.
9529    #[test]
9530    fn pick_open_pr_classifies_by_count_and_base() {
9531        let one = r#"[{"number":58,"url":"https://x/pull/58","title":"t","baseRefName":"main"}]"#;
9532        assert_eq!(
9533            pick_open_pr(one, "main").unwrap(),
9534            OpenPr::One {
9535                url: "https://x/pull/58".into(),
9536                title: "t".into()
9537            }
9538        );
9539        assert_eq!(pick_open_pr("[]", "main").unwrap(), OpenPr::None);
9540        assert_eq!(pick_open_pr(one, "dev").unwrap(), OpenPr::None);
9541        let two = r#"[{"number":1,"url":"u1","title":"","baseRefName":"main"},
9542                     {"number":2,"url":"u2","title":"","baseRefName":"main"}]"#;
9543        assert_eq!(
9544            pick_open_pr(two, "main").unwrap(),
9545            OpenPr::Many(vec!["u1".into(), "u2".into()])
9546        );
9547        // A fork's same-named branch is not a pull request for this head.
9548        let fork = r#"[{"url":"f","baseRefName":"main","isCrossRepository":true}]"#;
9549        assert_eq!(pick_open_pr(fork, "main").unwrap(), OpenPr::None);
9550        assert!(pick_open_pr("not json", "main").is_err());
9551        // An incomplete record is an error, never "nothing open".
9552        assert!(pick_open_pr(r#"[{"url":"u","title":"t"}]"#, "main").is_err());
9553        assert!(pick_open_pr(r#"[{"title":"t","baseRefName":"main"}]"#, "main").is_err());
9554    }
9555
9556    #[test]
9557    fn pick_merged_pr_picks_the_unique_match() {
9558        let json = r#"[
9559            {"url": "https://github.com/o/r/pull/42", "number": 42,
9560             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"}
9561        ]"#;
9562        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9563        let found = pick_merged_pr(json, "main", created_at)
9564            .expect("valid json")
9565            .expect("one unambiguous match");
9566        assert_eq!(found.url, "https://github.com/o/r/pull/42");
9567        assert_eq!(found.number, 42);
9568    }
9569
9570    /// Two candidates surviving the filter is exactly as uninformative as
9571    /// zero — a branch name can be reused across runs — so neither is
9572    /// preferred over the other and nothing is recorded automatically.
9573    #[test]
9574    fn pick_merged_pr_refuses_when_more_than_one_candidate_survives() {
9575        let json = r#"[
9576            {"url": "https://github.com/o/r/pull/42", "number": 42,
9577             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "main"},
9578            {"url": "https://github.com/o/r/pull/43", "number": 43,
9579             "mergedAt": "2026-09-21T10:00:00Z", "baseRefName": "main"}
9580        ]"#;
9581        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9582        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9583    }
9584
9585    /// A pull request that targets a different base branch cannot be this
9586    /// run's, whatever its head branch is named — a reused branch name from
9587    /// an unrelated task must not be recorded as this run's merge.
9588    #[test]
9589    fn pick_merged_pr_ignores_a_different_base_branch() {
9590        let json = r#"[
9591            {"url": "https://github.com/o/r/pull/42", "number": 42,
9592             "mergedAt": "2026-09-20T10:00:00Z", "baseRefName": "release"}
9593        ]"#;
9594        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9595        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9596    }
9597
9598    /// A pull request merged before this run was even created cannot be this
9599    /// run's winner, no matter how its head branch is spelled.
9600    #[test]
9601    fn pick_merged_pr_ignores_a_merge_that_predates_the_run() {
9602        let json = r#"[
9603            {"url": "https://github.com/o/r/pull/42", "number": 42,
9604             "mergedAt": "2026-09-18T10:00:00Z", "baseRefName": "main"}
9605        ]"#;
9606        let created_at: Timestamp = "2026-09-19T00:00:00Z".parse().unwrap();
9607        assert_eq!(pick_merged_pr(json, "main", created_at).unwrap(), None);
9608    }
9609
9610    #[test]
9611    fn slug_of_pr_url_reads_host_owner_and_repo() {
9612        assert_eq!(
9613            slug_of_pr_url("https://github.com/yukimemi/shun/pull/272").as_deref(),
9614            Some("github.com/yukimemi/shun")
9615        );
9616    }
9617
9618    #[test]
9619    fn slug_of_pr_url_refuses_a_url_with_no_pull_segment() {
9620        assert_eq!(slug_of_pr_url("https://github.com/yukimemi/shun"), None);
9621        assert_eq!(slug_of_pr_url("not a url at all"), None);
9622        assert_eq!(slug_of_pr_url("https://github.com"), None);
9623    }
9624
9625    #[test]
9626    fn slug_of_repo_url_reads_host_owner_and_repo() {
9627        assert_eq!(
9628            slug_of_repo_url("https://github.com/yukimemi/magi").as_deref(),
9629            Some("github.com/yukimemi/magi")
9630        );
9631        assert_eq!(slug_of_repo_url("https://github.com"), None);
9632    }
9633
9634    #[test]
9635    fn ensure_same_repo_accepts_a_matching_slug_regardless_of_case() {
9636        ensure_same_repo("github.com/yukimemi/magi", "GitHub.Com/YukiMemi/Magi")
9637            .expect("same repo, different case");
9638    }
9639
9640    /// The shun/8c75 incident: an id-less `--merged` picked this repository's
9641    /// own in-progress run and rewrote its status from a pull request in a
9642    /// completely different repository. This is the guard that must catch
9643    /// that even when an explicit (but wrong) id is given.
9644    #[test]
9645    fn ensure_same_repo_refuses_a_different_repo() {
9646        let err =
9647            ensure_same_repo("github.com/yukimemi/magi", "github.com/yukimemi/shun").unwrap_err();
9648        let msg = format!("{err:#}");
9649        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9650        assert!(msg.contains("github.com/yukimemi/shun"), "{msg}");
9651    }
9652
9653    /// Same owner/repo on two different forge hosts (a GitHub Enterprise
9654    /// instance mirroring a `github.com` repository's name, say) must not be
9655    /// treated as the same repository just because the trailing path
9656    /// matches.
9657    #[test]
9658    fn ensure_same_repo_refuses_the_same_slug_on_a_different_host() {
9659        let err = ensure_same_repo(
9660            "github.com/yukimemi/magi",
9661            "github.example.com/yukimemi/magi",
9662        )
9663        .unwrap_err();
9664        let msg = format!("{err:#}");
9665        assert!(msg.contains("github.com/yukimemi/magi"), "{msg}");
9666        assert!(msg.contains("github.example.com/yukimemi/magi"), "{msg}");
9667    }
9668
9669    /// No winner decided yet means there is no branch to ask GitHub about at
9670    /// all — `find_external_merge` must return `None` without ever spawning
9671    /// `gh`, which this proves by never providing a real repository to spawn
9672    /// it in.
9673    #[tokio::test]
9674    async fn find_external_merge_returns_none_without_a_winner() {
9675        let state = RunState::new(
9676            PathBuf::from("/no/such/repo"),
9677            "main".to_owned(),
9678            "0000000000000000000000000000000000000000".to_owned(),
9679            "irrelevant".to_owned(),
9680            crate::config::Config::default(),
9681        );
9682        assert_eq!(find_external_merge(&state).await.unwrap(), None);
9683    }
9684
9685    fn pr_run(home: &Path, id: &str, repo: &str, status: RunStatus, url: &str, state: &str) {
9686        let mut run = RunState::new(
9687            PathBuf::from(repo),
9688            "main".to_owned(),
9689            "abcdef1234".to_owned(),
9690            "x".to_owned(),
9691            crate::config::Config::default(),
9692        );
9693        run.id = id.to_owned();
9694        run.status = status;
9695        run.driver_exited = true;
9696        run.pr = Some(crate::run::PrRecord {
9697            number: url.rsplit('/').next().unwrap().parse().unwrap(),
9698            url: url.to_owned(),
9699            state: state.to_owned(),
9700            checks: "red".to_owned(),
9701            round: 0,
9702            rounds: 2,
9703            red_at_merge: Vec::new(),
9704        });
9705        run.save_under(home).unwrap();
9706    }
9707
9708    fn recorded(home: &Path, id: &str) -> String {
9709        let body = std::fs::read_to_string(home.join("runs").join(id).join("run.json")).unwrap();
9710        serde_json::from_str::<RunState>(&body)
9711            .unwrap()
9712            .pr
9713            .unwrap()
9714            .state
9715    }
9716
9717    const PR: &str = "https://github.com/o/r/pull/7";
9718
9719    #[test]
9720    fn write_through_updates_predecessors_and_siblings_only() {
9721        let tmp = tempfile::tempdir().unwrap();
9722        let h = tmp.path();
9723        pr_run(
9724            h,
9725            "20261004-100000-aaaa",
9726            "/repo/r",
9727            RunStatus::Superseded,
9728            PR,
9729            "open",
9730        );
9731        pr_run(
9732            h,
9733            "20261004-100100-bbbb",
9734            "/repo/r",
9735            RunStatus::Blocked,
9736            PR,
9737            "open",
9738        );
9739        // Not terminal: a driver may be writing it.
9740        pr_run(
9741            h,
9742            "20261004-100200-cccc",
9743            "/repo/r",
9744            RunStatus::Landing,
9745            PR,
9746            "open",
9747        );
9748        // Another repository's pull request with the same number.
9749        pr_run(
9750            h,
9751            "20261004-100300-dddd",
9752            "/repo/other",
9753            RunStatus::Blocked,
9754            "https://github.com/o/other/pull/7",
9755            "open",
9756        );
9757        // A different pull request of the same repository.
9758        pr_run(
9759            h,
9760            "20261004-100400-eeee",
9761            "/repo/r",
9762            RunStatus::Blocked,
9763            "https://github.com/o/r/pull/8",
9764            "open",
9765        );
9766        pr_run(
9767            h,
9768            "20261004-100500-ffff",
9769            "/repo/r",
9770            RunStatus::Merged,
9771            PR,
9772            "open",
9773        );
9774        let source = RunState::load_under("20261004-100500-ffff", h).unwrap();
9775
9776        assert_eq!(
9777            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9778            2
9779        );
9780        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9781        assert_eq!(recorded(h, "20261004-100100-bbbb"), "merged");
9782        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9783        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9784        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9785        // The source's own record is the caller's to write.
9786        assert_eq!(recorded(h, "20261004-100500-ffff"), "open");
9787        // Idempotent.
9788        assert_eq!(
9789            write_pr_state_through_in(h, &source, PrLifecycle::Merged),
9790            0
9791        );
9792        let hit = RunState::load_under("20261004-100000-aaaa", h).unwrap();
9793        assert!(hit.events.iter().any(|e| e.message.contains("merged")));
9794    }
9795
9796    #[test]
9797    fn lifecycle_updates_preserve_live_and_unknown_terminal_drivers() {
9798        let home = tempfile::tempdir().unwrap();
9799        let live_id = "20261004-100000-aaaa";
9800        let unknown_id = "20261004-100100-bbbb";
9801        for id in [live_id, unknown_id] {
9802            pr_run(home.path(), id, "repo", RunStatus::Ready, PR, "open");
9803            let mut s = RunState::load_under(id, home.path()).unwrap();
9804            s.driver_exited = false;
9805            // Model legacy driver evidence, not a newly reserved unstarted run.
9806            s.driver_claimed = None;
9807            if id == live_id {
9808                s.driver_pid = Some(std::process::id());
9809                s.driver_started_at = crate::proc::process_started_at(std::process::id());
9810            }
9811            s.save_under(home.path()).unwrap();
9812            if id == unknown_id {
9813                assert_eq!(
9814                    s.liveness_under(home.path(), false),
9815                    crate::run::Liveness::Unknown
9816                );
9817            }
9818        }
9819        let known = BTreeMap::from([(PR.to_owned(), PrLifecycle::Merged)]);
9820        assert_eq!(apply_pr_states(home.path(), &known), 0);
9821        for id in [live_id, unknown_id] {
9822            assert_eq!(recorded(home.path(), id), "open");
9823        }
9824    }
9825
9826    #[test]
9827    fn repair_rewrites_merged_and_closed_and_leaves_open_and_unknown() {
9828        let tmp = tempfile::tempdir().unwrap();
9829        let h = tmp.path();
9830        let url = |n: u32| format!("https://github.com/o/r/pull/{n}");
9831        pr_run(
9832            h,
9833            "20261004-100000-aaaa",
9834            "/repo/r",
9835            RunStatus::Superseded,
9836            &url(1),
9837            "open",
9838        );
9839        pr_run(
9840            h,
9841            "20261004-100100-bbbb",
9842            "/repo/r",
9843            RunStatus::Blocked,
9844            &url(2),
9845            "open",
9846        );
9847        pr_run(
9848            h,
9849            "20261004-100200-cccc",
9850            "/repo/r",
9851            RunStatus::Ready,
9852            &url(3),
9853            "open",
9854        );
9855        pr_run(
9856            h,
9857            "20261004-100300-dddd",
9858            "/repo/r",
9859            RunStatus::Ready,
9860            &url(4),
9861            "open",
9862        );
9863        pr_run(
9864            h,
9865            "20261004-100400-eeee",
9866            "/repo/r",
9867            RunStatus::Implementing,
9868            &url(1),
9869            "open",
9870        );
9871        assert_eq!(stale_open_prs(h).len(), 4);
9872
9873        let mut known = BTreeMap::new();
9874        known.insert(url(1), PrLifecycle::Merged);
9875        known.insert(url(2), PrLifecycle::Closed);
9876        known.insert(url(3), PrLifecycle::Open);
9877        // #4: the forge could not be read, so it has no answer.
9878        assert_eq!(apply_pr_states(h, &known), 2);
9879        assert_eq!(recorded(h, "20261004-100000-aaaa"), "merged");
9880        assert_eq!(recorded(h, "20261004-100100-bbbb"), "closed");
9881        assert_eq!(recorded(h, "20261004-100200-cccc"), "open");
9882        assert_eq!(recorded(h, "20261004-100300-dddd"), "open");
9883        assert_eq!(recorded(h, "20261004-100400-eeee"), "open");
9884        assert_eq!(apply_pr_states(h, &known), 0);
9885    }
9886
9887    // --- the land loop against a scripted forge -------------------------
9888
9889    use std::collections::VecDeque;
9890    use std::sync::Mutex;
9891
9892    /// Answers views from a script (the last one repeats), merges from a
9893    /// queue, and records every call so a test can assert the order.
9894    struct Scripted {
9895        evidence: Mutex<Option<CiEvidence>>,
9896        views: Mutex<VecDeque<Seen>>,
9897        merges: Mutex<VecDeque<(bool, String)>>,
9898        fix: Mutex<Option<Fixed>>,
9899        log: Mutex<Vec<&'static str>>,
9900        argvs: Mutex<Vec<Vec<String>>>,
9901        required: Mutex<Option<BTreeSet<String>>>,
9902        /// Set once a merge answered ok: the forge then reports `merged`,
9903        /// unless `queued` says the merge only entered a queue.
9904        merged: Mutex<bool>,
9905        queued: Mutex<bool>,
9906        /// Views fail once a merge answered ok.
9907        unreadable_after_merge: Mutex<bool>,
9908        /// Workflow runs asked to rerun, and whether that call fails.
9909        reruns: Mutex<Vec<String>>,
9910        rerun_fails: Mutex<bool>,
9911        /// Unix seconds; every poll moves it on by `POLL`.
9912        clock: Mutex<i64>,
9913    }
9914
9915    impl Scripted {
9916        fn new(views: Vec<Seen>, merges: Vec<(bool, &str)>) -> Self {
9917            Self {
9918                evidence: Mutex::new(None),
9919                views: Mutex::new(views.into()),
9920                merges: Mutex::new(
9921                    merges
9922                        .into_iter()
9923                        .map(|(ok, m)| (ok, m.to_owned()))
9924                        .collect(),
9925                ),
9926                fix: Mutex::new(None),
9927                log: Mutex::new(Vec::new()),
9928                argvs: Mutex::new(Vec::new()),
9929                required: Mutex::new(None),
9930                merged: Mutex::new(false),
9931                queued: Mutex::new(false),
9932                unreadable_after_merge: Mutex::new(false),
9933                reruns: Mutex::new(Vec::new()),
9934                rerun_fails: Mutex::new(false),
9935                clock: Mutex::new(1_000),
9936            }
9937        }
9938        fn argvs(&self) -> Vec<Vec<String>> {
9939            self.argvs.lock().unwrap().clone()
9940        }
9941        fn calls(&self) -> Vec<&'static str> {
9942            self.log.lock().unwrap().clone()
9943        }
9944    }
9945
9946    impl Forge for Scripted {
9947        async fn attribution(&self, _state: &RunState, _seen: &Seen) -> Option<CiEvidence> {
9948            self.evidence.lock().unwrap().clone()
9949        }
9950        async fn view(&self, _repo: &Path, _url: &str) -> Result<Seen> {
9951            self.log.lock().unwrap().push("view");
9952            if *self.unreadable_after_merge.lock().unwrap()
9953                && !self.argvs.lock().unwrap().is_empty()
9954            {
9955                anyhow::bail!("forge unreachable");
9956            }
9957            let mut v = self.views.lock().unwrap();
9958            let mut seen = if v.len() > 1 {
9959                v.pop_front().unwrap()
9960            } else {
9961                v[0].clone()
9962            };
9963            if *self.merged.lock().unwrap() {
9964                seen.pr.state = PrLifecycle::Merged;
9965            }
9966            Ok(seen)
9967        }
9968        async fn merge(&self, _repo: &Path, argv: &[String]) -> Result<(bool, String)> {
9969            self.log.lock().unwrap().push("merge");
9970            self.argvs.lock().unwrap().push(argv.to_vec());
9971            let out = self
9972                .merges
9973                .lock()
9974                .unwrap()
9975                .pop_front()
9976                .expect("unscripted merge");
9977            if out.0 && !*self.queued.lock().unwrap() && !argv.iter().any(|a| a == "--disable-auto")
9978            {
9979                *self.merged.lock().unwrap() = true;
9980            }
9981            Ok(out)
9982        }
9983        async fn poll(&self) {
9984            self.log.lock().unwrap().push("poll");
9985            *self.clock.lock().unwrap() += POLL.as_secs() as i64;
9986        }
9987        async fn rerun(&self, _repo: &Path, run: &str) -> Result<()> {
9988            self.log.lock().unwrap().push("rerun");
9989            self.reruns.lock().unwrap().push(run.to_owned());
9990            if *self.rerun_fails.lock().unwrap() {
9991                anyhow::bail!("gh run rerun: no");
9992            }
9993            Ok(())
9994        }
9995        fn now(&self) -> i64 {
9996            *self.clock.lock().unwrap()
9997        }
9998        async fn required_contexts(&self, _repo: &Path, _base: &str) -> Option<BTreeSet<String>> {
9999            self.required.lock().unwrap().clone()
10000        }
10001        async fn fix(
10002            &self,
10003            _state: &mut RunState,
10004            _pr: &PrState,
10005            _round: usize,
10006            _budget: usize,
10007            _reason: &str,
10008            _logs: &str,
10009        ) -> Result<Fixed> {
10010            self.log.lock().unwrap().push("fix");
10011            Ok(self.fix.lock().unwrap().take().expect("unscripted fix"))
10012        }
10013    }
10014
10015    const REFUSED: &str =
10016        "X Pull request #42 is not mergeable: the base branch policy prohibits the merge.";
10017
10018    fn seen(head: &str, checks: Checks, merge_state: &str, comments: bool) -> Seen {
10019        let mut pr = green_pr();
10020        pr.checks = checks;
10021        pr.blocking = Blocking::of(merge_state);
10022        if !comments {
10023            pr.review_comments.clear();
10024        }
10025        Seen {
10026            pr,
10027            title: "feat: x".to_owned(),
10028            failing_urls: Vec::new(),
10029            head: head.to_owned(),
10030            rollup_head: head.to_owned(),
10031            merge_state: merge_state.to_owned(),
10032            contexts: Vec::new(),
10033            base: "main".to_owned(),
10034        }
10035    }
10036
10037    fn landing_state() -> RunState {
10038        crate::run::pin_test_home();
10039        let mut state = run_state();
10040        state.config.graph.land_approval = false;
10041        state
10042    }
10043
10044    #[test]
10045    fn a_pushed_head_is_awaited_case_insensitively_and_an_unreadable_one_is_not_a_match() {
10046        assert!(!awaiting_new_head(None, "aaa"));
10047        assert!(!awaiting_new_head(Some("abc123"), "ABC123"));
10048        assert!(awaiting_new_head(Some("abc123"), "def456"));
10049        assert!(awaiting_new_head(Some("abc123"), ""));
10050    }
10051
10052    #[test]
10053    fn a_refusal_is_judged_by_the_pull_requests_state_not_by_its_wording() {
10054        let open = |c, m: &str| seen("a", c, m, false);
10055        let table = [
10056            (None, false, Refused::Pending),
10057            (
10058                Some(open(Checks::Pending, "BLOCKED")),
10059                false,
10060                Refused::Pending,
10061            ),
10062            (
10063                Some(open(Checks::Unknown, "BLOCKED")),
10064                false,
10065                Refused::Pending,
10066            ),
10067            (
10068                Some(open(Checks::Green, "UNKNOWN")),
10069                false,
10070                Refused::Pending,
10071            ),
10072            (Some(open(Checks::Green, "")), false, Refused::Pending),
10073            (
10074                Some(open(Checks::Green, "BLOCKED")),
10075                false,
10076                Refused::Recheck,
10077            ),
10078            (Some(open(Checks::Green, "BLOCKED")), true, Refused::Final),
10079        ];
10080        for (after, rechecked, want) in table {
10081            assert_eq!(
10082                classify_refusal(after.as_ref(), rechecked, "a", CiExpectation::Expected),
10083                want
10084            );
10085        }
10086        let mut closed = open(Checks::Green, "CLEAN");
10087        closed.pr.state = PrLifecycle::Closed;
10088        assert_eq!(
10089            classify_refusal(Some(&closed), false, "a", CiExpectation::Expected),
10090            Refused::Final
10091        );
10092    }
10093
10094    #[tokio::test]
10095    async fn a_normal_landing_merges_on_the_first_look() {
10096        let mut state = landing_state();
10097        let forge = Scripted::new(
10098            vec![seen("a", Checks::Green, "CLEAN", false)],
10099            vec![(true, "")],
10100        );
10101        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10102            .await
10103            .unwrap();
10104        // One fresh read, then the head-bound merge.
10105        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10106        assert_eq!(state.status, RunStatus::Merged);
10107    }
10108
10109    #[tokio::test]
10110    async fn a_successful_merge_command_that_only_queued_is_not_a_merge() {
10111        let mut state = landing_state();
10112        let forge = Scripted::new(
10113            vec![seen("a", Checks::Green, "CLEAN", false)],
10114            std::iter::repeat_n((true, ""), 100).collect(),
10115        );
10116        *forge.queued.lock().unwrap() = true;
10117        let task = async {
10118            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10119                .await
10120                .unwrap();
10121        };
10122        // Still open after the command succeeded: it keeps watching and
10123        // never records a merge (it stops at the wait ceiling instead).
10124        task.await;
10125        assert_ne!(state.status, RunStatus::Merged);
10126    }
10127
10128    #[tokio::test]
10129    async fn an_unreadable_forge_after_a_merge_command_is_not_a_confirmation() {
10130        let mut state = landing_state();
10131        let forge = Scripted::new(
10132            vec![seen("a", Checks::Green, "CLEAN", false)],
10133            std::iter::repeat_n((true, ""), 100).collect(),
10134        );
10135        *forge.unreadable_after_merge.lock().unwrap() = true;
10136        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10137            .await
10138            .ok();
10139        assert_ne!(state.status, RunStatus::Merged);
10140    }
10141
10142    #[tokio::test]
10143    async fn a_task_public_head_is_awaited_on_land_entry() {
10144        let mut state = landing_state();
10145        state.pr_head = Some("magi/task-abcd-1234".into());
10146        state.pr_pushed_head = Some("new".into());
10147        let forge = Scripted::new(
10148            vec![
10149                seen("old", Checks::Green, "CLEAN", false),
10150                seen("new", Checks::Green, "CLEAN", false),
10151            ],
10152            vec![(true, "")],
10153        );
10154        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10155            .await
10156            .unwrap();
10157        assert_eq!(
10158            forge.calls(),
10159            ["view", "poll", "view", "view", "merge", "view"]
10160        );
10161        assert_eq!(state.status, RunStatus::Merged);
10162    }
10163
10164    #[tokio::test]
10165    async fn a_superseded_public_head_owner_cannot_reenter_land() {
10166        let mut state = landing_state();
10167        state.pr_taken_over_by = Some("next".into());
10168        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", false)], vec![]);
10169        assert!(
10170            land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10171                .await
10172                .is_err()
10173        );
10174        assert!(forge.calls().is_empty());
10175    }
10176
10177    #[tokio::test]
10178    async fn after_a_pushed_fix_no_merge_is_tried_until_the_head_matches() {
10179        let mut state = landing_state();
10180        let forge = Scripted::new(
10181            vec![
10182                seen("old", Checks::Green, "CLEAN", true),
10183                // The forge has not moved to the new head yet: still green.
10184                seen("old", Checks::Green, "CLEAN", true),
10185                seen("new", Checks::Pending, "BLOCKED", true),
10186                seen("new", Checks::Green, "CLEAN", true),
10187            ],
10188            vec![(true, "")],
10189        );
10190        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10191            head: "NEW".to_owned(),
10192        });
10193        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10194            .await
10195            .unwrap();
10196        assert_eq!(
10197            forge.calls(),
10198            [
10199                "view", "fix", "poll", "view", "poll", "view", "poll", "view", "view", "merge",
10200                "view"
10201            ]
10202        );
10203        assert_eq!(state.status, RunStatus::Merged);
10204    }
10205
10206    #[tokio::test]
10207    async fn a_head_that_never_arrives_stops_naming_both_commits() {
10208        let mut state = landing_state();
10209        let forge = Scripted::new(
10210            vec![
10211                seen("old", Checks::Green, "CLEAN", true),
10212                seen("someone-elses", Checks::Green, "CLEAN", true),
10213            ],
10214            vec![],
10215        );
10216        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10217            head: "mine".to_owned(),
10218        });
10219        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10220            .await
10221            .unwrap();
10222        assert!(!forge.calls().contains(&"merge"));
10223        let why = state.merge.as_ref().unwrap().detail.clone();
10224        assert!(
10225            why.contains("mine") && why.contains("someone-elses"),
10226            "{why}"
10227        );
10228        assert_eq!(state.status, RunStatus::Blocked);
10229    }
10230
10231    #[tokio::test]
10232    async fn a_policy_refusal_while_checks_run_waits_and_then_merges() {
10233        let mut state = landing_state();
10234        let forge = Scripted::new(
10235            vec![
10236                seen("a", Checks::Green, "CLEAN", false),
10237                seen("a", Checks::Green, "CLEAN", false),
10238                seen("a", Checks::Pending, "BLOCKED", false),
10239                seen("a", Checks::Pending, "BLOCKED", false),
10240                seen("a", Checks::Green, "CLEAN", false),
10241            ],
10242            vec![(false, REFUSED), (true, "")],
10243        );
10244        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10245            .await
10246            .unwrap();
10247        assert_eq!(
10248            forge.calls(),
10249            [
10250                "view", "view", "merge", "view", "poll", "view", "poll", "view", "view", "merge",
10251                "view"
10252            ]
10253        );
10254        assert_eq!(state.status, RunStatus::Merged);
10255    }
10256
10257    #[tokio::test]
10258    async fn a_refusal_that_outlives_settled_checks_stops_with_the_merge_state() {
10259        let mut state = landing_state();
10260        let forge = Scripted::new(
10261            vec![
10262                seen("a", Checks::Green, "CLEAN", false),
10263                seen("a", Checks::Green, "BLOCKED", false),
10264            ],
10265            vec![(false, REFUSED), (false, REFUSED)],
10266        );
10267        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10268            .await
10269            .unwrap();
10270        // One re-look is allowed for the forge's own lag, then it is final.
10271        assert_eq!(forge.calls().iter().filter(|c| **c == "merge").count(), 2);
10272        let why = state.merge.as_ref().unwrap().detail.clone();
10273        assert!(
10274            why.contains("policy prohibits") && why.contains("BLOCKED") && why.contains("refused"),
10275            "{why}"
10276        );
10277        assert_eq!(state.status, RunStatus::Blocked);
10278    }
10279
10280    #[test]
10281    fn a_decision_is_bound_to_a_head_only_when_every_signal_agrees() {
10282        assert_eq!(bound_head("abc", "abc", None), Some("abc"));
10283        assert_eq!(bound_head("abc", "ABC", Some("abc")), Some("abc"));
10284        // The pull request is still on the commit before the push.
10285        assert_eq!(bound_head("old", "old", Some("new")), None);
10286        // The checks are the previous commit's.
10287        assert_eq!(bound_head("new", "old", Some("new")), None);
10288        assert_eq!(bound_head("new", "old", None), None);
10289        // Nothing readable.
10290        assert_eq!(bound_head("", "", None), None);
10291        assert_eq!(bound_head("", "", Some("new")), None);
10292        assert_eq!(bound_head("abc", "", None), None);
10293    }
10294
10295    fn view_json(head: &str) -> String {
10296        format!(
10297            r#"{{"url":"https://github.com/o/r/pull/42","number":42,"state":"OPEN",
10298            "title":"t","headRefOid":"{head}","mergeStateStatus":"CLEAN",
10299            "reviews":[],"comments":[]}}"#
10300        )
10301    }
10302
10303    fn node_json(oid: &str, check: &str, has_next: bool) -> String {
10304        format!(
10305            r#"{{"data":{{"repository":{{"pullRequest":{{"commits":{{"nodes":[{{"commit":
10306            {{"oid":"{oid}","statusCheckRollup":{{"contexts":{{"pageInfo":{{"hasNextPage":{has_next}}},
10307            "nodes":[{{"__typename":"CheckRun","name":"ci","status":"COMPLETED",
10308            "conclusion":"{check}","detailsUrl":"https://example.test/1"}}]}}}}}}}}]}}}}}}}}}}"#
10309        )
10310    }
10311
10312    #[test]
10313    fn rollup_is_bound_to_the_commit_in_the_same_node() {
10314        // The view already points at the new head, but the node still answers
10315        // for the old commit with its red check: the head stays unbound.
10316        let s = seen_from(&view_json("new"), Some(&node_json("old", "FAILURE", false))).unwrap();
10317        assert_eq!(s.rollup_head, "old");
10318        assert_eq!(s.pr.checks, Checks::Red);
10319        assert_eq!(bound_head(&s.head, &s.rollup_head, Some("new")), None);
10320        assert_eq!(s.failing_urls.len(), 1);
10321    }
10322
10323    #[test]
10324    fn checks_come_from_the_node_not_the_view() {
10325        let view = view_json("new").replace(
10326            r#""reviews""#,
10327            r#""statusCheckRollup":[{"name":"ci","status":"COMPLETED","conclusion":"FAILURE"}],"reviews""#,
10328        );
10329        let s = seen_from(&view, Some(&node_json("new", "SUCCESS", false))).unwrap();
10330        assert_eq!(s.pr.checks, Checks::Green);
10331        assert!(s.pr.failing.is_empty());
10332        assert_eq!(
10333            bound_head(&s.head, &s.rollup_head, Some("new")),
10334            Some("new")
10335        );
10336    }
10337
10338    #[test]
10339    fn an_unreadable_or_paged_node_leaves_the_head_unbound() {
10340        for node in [
10341            None,
10342            Some("not json".to_owned()),
10343            Some(r#"{"errors":[{"message":"x"}]}"#.to_owned()),
10344            Some(node_json("new", "SUCCESS", true)),
10345        ] {
10346            let s = seen_from(&view_json("new"), node.as_deref()).unwrap();
10347            assert!(s.rollup_head.is_empty());
10348            assert_eq!(s.pr.checks, Checks::Unknown);
10349            assert_eq!(bound_head(&s.head, &s.rollup_head, None), None);
10350        }
10351    }
10352
10353    #[test]
10354    fn the_merge_command_is_pinned_to_the_observed_head() {
10355        let argv = merge_argv_at(7, "feat: x", "deadbeef");
10356        let at = argv
10357            .iter()
10358            .position(|a| a == "--match-head-commit")
10359            .unwrap();
10360        assert_eq!(argv[at + 1], "deadbeef");
10361    }
10362
10363    #[tokio::test]
10364    async fn stale_checks_after_a_fix_push_never_reach_a_merge() {
10365        let mut state = landing_state();
10366        // The pull request is on the pushed head but the rollup is still the
10367        // previous commit's red, non-required result.
10368        let mut stale = seen("new", Checks::Red, "CLEAN", false);
10369        stale.rollup_head = "old".to_owned();
10370        let forge = Scripted::new(
10371            vec![seen("old", Checks::Green, "CLEAN", true), stale],
10372            vec![],
10373        );
10374        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
10375            head: "new".to_owned(),
10376        });
10377        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10378            .await
10379            .unwrap();
10380        assert!(!forge.calls().contains(&"merge"));
10381        assert_eq!(state.status, RunStatus::Blocked);
10382        let why = state.merge.as_ref().unwrap().detail.clone();
10383        assert!(why.contains("new") && why.contains("old"), "{why}");
10384    }
10385
10386    #[test]
10387    fn a_refusal_read_against_another_commits_checks_is_pending() {
10388        let mut after = seen("a", Checks::Green, "BLOCKED", false);
10389        after.rollup_head = "old".to_owned();
10390        assert_eq!(
10391            classify_refusal(Some(&after), true, "a", CiExpectation::Expected),
10392            Refused::Pending
10393        );
10394    }
10395
10396    #[tokio::test]
10397    async fn absent_ci_empty_rollup_reaches_approval_then_merges_on_the_approved_head() {
10398        let mut state = landing_state();
10399        state.config.merge.ci = crate::config::MergeCi::None;
10400        state.config.graph.land_approval = true;
10401        let forge = Scripted::new(
10402            vec![seen("a", Checks::Unknown, "CLEAN", false)],
10403            vec![(true, "")],
10404        );
10405        land_with(&mut state, URL, &forge).await.unwrap();
10406        assert!(state.parked);
10407        assert_eq!(forge.calls(), ["view"]);
10408        let store = ask::Questions::open();
10409        let mut q = store
10410            .get(&state.land_approval.as_ref().unwrap().question)
10411            .unwrap();
10412        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10413        store.put(&mut q).unwrap();
10414
10415        land_with(&mut state, URL, &forge).await.unwrap();
10416        assert_eq!(forge.calls(), ["view", "view", "view", "merge", "view"]);
10417        assert_eq!(state.status, RunStatus::Merged);
10418        assert_eq!(
10419            value_of(&forge.argvs()[0], "--match-head-commit"),
10420            Some("a")
10421        );
10422    }
10423
10424    #[tokio::test]
10425    async fn absent_ci_ignores_failed_workflow_runs_without_rerunning_them() {
10426        let mut state = landing_state();
10427        state.config.merge.ci = crate::config::MergeCi::None;
10428        let forge = Scripted::new(vec![red("a", Some("7"))], vec![(true, "")]);
10429        land_with(&mut state, URL, &forge).await.unwrap();
10430        assert!(forge.reruns.lock().unwrap().is_empty());
10431        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10432        assert_eq!(state.status, RunStatus::Merged);
10433    }
10434
10435    #[test]
10436    fn absent_ci_still_requires_a_readable_matching_head_and_resolved_comments() {
10437        let shown = BTreeSet::new();
10438        let guard = |s: Option<&Seen>| {
10439            direct_merge_is_safe(s, "a", &shown, 0, 4, Duration::ZERO, CiExpectation::Absent)
10440        };
10441        let mut fresh = seen("a", Checks::Unknown, "CLEAN", false);
10442        assert!(guard(Some(&fresh)));
10443        assert!(!guard(None));
10444        fresh.rollup_head = "old".to_owned();
10445        assert!(!guard(Some(&fresh)));
10446        fresh.rollup_head = "a".to_owned();
10447        fresh.head = "b".to_owned();
10448        assert!(!guard(Some(&fresh)));
10449        assert!(!guard(Some(&seen("a", Checks::Unknown, "CLEAN", true))));
10450        assert!(!guard(Some(&seen("a", Checks::Unknown, "DIRTY", false))));
10451    }
10452
10453    #[test]
10454    fn absent_ci_refusal_does_not_wait_for_third_party_checks() {
10455        for checks in [Checks::Unknown, Checks::Pending, Checks::Red, Checks::Green] {
10456            let after = seen("a", checks, "BLOCKED", false);
10457            assert_eq!(
10458                classify_refusal(Some(&after), false, "a", CiExpectation::Absent),
10459                Refused::Recheck
10460            );
10461            assert_eq!(
10462                classify_refusal(Some(&after), true, "a", CiExpectation::Absent),
10463                Refused::Final
10464            );
10465        }
10466    }
10467
10468    #[tokio::test]
10469    async fn a_matching_head_with_red_non_required_checks_still_merges() {
10470        let mut state = landing_state();
10471        let forge = Scripted::new(
10472            vec![seen("a", Checks::Red, "CLEAN", false)],
10473            vec![(true, "")],
10474        );
10475        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10476            .await
10477            .unwrap();
10478        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10479        assert_eq!(state.status, RunStatus::Merged);
10480    }
10481
10482    #[tokio::test]
10483    async fn a_merge_refused_because_the_head_moved_looks_again_instead_of_failing() {
10484        let mut state = landing_state();
10485        let forge = Scripted::new(
10486            vec![
10487                seen("a", Checks::Green, "CLEAN", false),
10488                seen("a", Checks::Green, "CLEAN", false),
10489                // Re-viewed after the refusal: someone pushed.
10490                seen("b", Checks::Green, "BLOCKED", false),
10491                seen("b", Checks::Green, "CLEAN", false),
10492            ],
10493            vec![(false, REFUSED), (true, "")],
10494        );
10495        land_with(&mut state, "https://github.com/o/r/pull/42", &forge)
10496            .await
10497            .unwrap();
10498        assert_eq!(
10499            forge.calls(),
10500            [
10501                "view", "view", "merge", "view", "poll", "view", "view", "merge", "view"
10502            ]
10503        );
10504        assert_eq!(state.status, RunStatus::Merged);
10505    }
10506
10507    fn merged_view(head: &str) -> Seen {
10508        let mut m = seen(head, Checks::Green, "CLEAN", false);
10509        m.pr.state = PrLifecycle::Merged;
10510        m
10511    }
10512
10513    fn has(argv: &[String], flag: &str) -> bool {
10514        argv.iter().any(|a| a == flag)
10515    }
10516
10517    fn value_of<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> {
10518        let at = argv.iter().position(|a| a == flag)?;
10519        argv.get(at + 1).map(String::as_str)
10520    }
10521
10522    const URL: &str = "https://github.com/o/r/pull/42";
10523
10524    #[tokio::test]
10525    async fn the_merge_step_merges_directly_on_the_observed_head_and_never_arms() {
10526        let mut state = landing_state();
10527        let forge = Scripted::new(
10528            vec![
10529                seen("abc", Checks::Green, "CLEAN", false),
10530                seen("abc", Checks::Green, "CLEAN", false),
10531            ],
10532            vec![(true, "")],
10533        );
10534        land_with(&mut state, URL, &forge).await.unwrap();
10535        assert_eq!(forge.calls(), ["view", "view", "merge", "view"]);
10536        let argv = &forge.argvs()[0];
10537        assert!(has(argv, "--squash") && has(argv, "--subject"));
10538        assert!(!has(argv, "--auto") && !has(argv, "--admin"));
10539        assert_eq!(value_of(argv, "--match-head-commit"), Some("abc"));
10540        assert_eq!(state.status, RunStatus::Merged);
10541        assert!(state.land_armed_head.is_none());
10542    }
10543
10544    #[tokio::test]
10545    async fn a_resume_disables_an_arm_left_by_an_older_build_before_merging() {
10546        let mut state = landing_state();
10547        state.land_armed_head = Some("a".to_owned());
10548        let forge = Scripted::new(
10549            vec![seen("a", Checks::Green, "CLEAN", false)],
10550            vec![(true, ""), (true, "")],
10551        );
10552        land_with(&mut state, URL, &forge).await.unwrap();
10553        let argvs = forge.argvs();
10554        assert!(has(&argvs[0], "--disable-auto"));
10555        assert!(!has(&argvs[1], "--auto"));
10556        assert_eq!(value_of(&argvs[1], "--match-head-commit"), Some("a"));
10557        assert_eq!(state.status, RunStatus::Merged);
10558        assert!(state.land_armed_head.is_none());
10559    }
10560
10561    #[tokio::test]
10562    async fn an_approval_never_carries_over_to_a_new_head() {
10563        crate::run::pin_test_home();
10564        let mut state = run_state();
10565        state.config.graph.land_approval = true;
10566        let pr = green_pr();
10567        let store = ask::Questions::open();
10568
10569        approval_gate(&mut state, &pr, "feat: x", None, "aaa")
10570            .await
10571            .unwrap();
10572        let mut q = store
10573            .list()
10574            .into_iter()
10575            .find(|q| q.run == state.id)
10576            .unwrap();
10577        q.answer(ask::Answer::Choice(APPROVE.to_owned())).unwrap();
10578        store.put(&mut q).unwrap();
10579        assert_eq!(
10580            approval_gate(&mut state, &pr, "feat: x", None, "AAA")
10581                .await
10582                .unwrap(),
10583            ApprovalGate::Approved,
10584            "the same head keeps its approval"
10585        );
10586
10587        // A new head is a new question, not the old word.
10588        assert_eq!(
10589            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10590                .await
10591                .unwrap(),
10592            ApprovalGate::Pending
10593        );
10594        let all: Vec<_> = store
10595            .list()
10596            .into_iter()
10597            .filter(|q| q.run == state.id)
10598            .collect();
10599        assert_eq!(all.len(), 2);
10600
10601        // A question recorded before heads were tracked is not reused either.
10602        state.land_approval = None;
10603        assert_eq!(
10604            approval_gate(&mut state, &pr, "feat: x", None, "bbb")
10605                .await
10606                .unwrap(),
10607            ApprovalGate::Pending
10608        );
10609        let open = store
10610            .list()
10611            .into_iter()
10612            .filter(|q| q.run == state.id && q.status.open())
10613            .count();
10614        assert_eq!(open, 1, "the superseded question was retired");
10615    }
10616
10617    #[tokio::test]
10618    async fn the_merge_is_bound_to_the_head_it_was_decided_on() {
10619        let mut state = landing_state();
10620        let forge = Scripted::new(
10621            vec![
10622                seen("a", Checks::Green, "CLEAN", false),
10623                // The fresh read before the merge: someone pushed.
10624                seen("b", Checks::Green, "CLEAN", false),
10625            ],
10626            vec![(true, "")],
10627        );
10628        land_with(&mut state, URL, &forge).await.unwrap();
10629        let argvs = forge.argvs();
10630        assert_eq!(argvs.len(), 1, "no merge was tried on the moved head");
10631        assert!(!has(&argvs[0], "--auto"));
10632        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("b"));
10633        assert_eq!(state.status, RunStatus::Merged);
10634    }
10635
10636    fn passing(label: &str) -> CheckInfo {
10637        CheckInfo {
10638            label: label.to_owned(),
10639            verdict: Verdict::Pass,
10640            required: Some(false),
10641            run: None,
10642        }
10643    }
10644
10645    fn failing(label: &str, run: Option<&str>) -> (String, Option<String>) {
10646        (label.to_owned(), run.map(str::to_owned))
10647    }
10648
10649    fn reran(head: &str, runs: &[(&str, i64)]) -> LandReruns {
10650        LandReruns {
10651            head: head.to_owned(),
10652            runs: runs.iter().map(|(r, at)| ((*r).to_owned(), *at)).collect(),
10653        }
10654    }
10655
10656    #[test]
10657    fn a_failed_run_is_rerun_once_then_waited_on_then_left_to_the_fixer() {
10658        let f = [failing("test (macos)", Some("7"))];
10659        assert_eq!(
10660            rerun_step(&f, None, "h", 100),
10661            RerunStep::Rerun(vec!["7".to_owned()])
10662        );
10663        let rec = reran("h", &[("7", 100)]);
10664        let grace = crate::release_watch::RERUN_GRACE;
10665        assert_eq!(
10666            rerun_step(&f, Some(&rec), "h", 100 + grace - 1),
10667            RerunStep::Grace
10668        );
10669        assert_eq!(rerun_step(&f, Some(&rec), "h", 100 + grace), RerunStep::Fix);
10670    }
10671
10672    #[test]
10673    fn a_failure_without_a_run_id_goes_to_the_fixer() {
10674        let f = [failing("a", Some("7")), failing("ci/external", None)];
10675        assert_eq!(rerun_step(&f, None, "h", 0), RerunStep::Fix);
10676        assert_eq!(rerun_step(&[], None, "h", 0), RerunStep::Fix);
10677    }
10678
10679    #[test]
10680    fn a_moved_head_forgets_the_reruns() {
10681        let f = [failing("a", Some("7"))];
10682        let rec = reran("old", &[("7", 100)]);
10683        assert_eq!(
10684            rerun_step(&f, Some(&rec), "new", 101),
10685            RerunStep::Rerun(vec!["7".to_owned()])
10686        );
10687    }
10688
10689    #[test]
10690    fn only_unrerun_runs_are_rerun_and_duplicates_collapse() {
10691        let f = [
10692            failing("a", Some("7")),
10693            failing("b", Some("7")),
10694            failing("c", Some("8")),
10695        ];
10696        assert_eq!(
10697            rerun_step(&f, None, "h", 0),
10698            RerunStep::Rerun(vec!["7".to_owned(), "8".to_owned()])
10699        );
10700        let rec = reran("h", &[("7", 0)]);
10701        assert_eq!(
10702            rerun_step(&f, Some(&rec), "h", 1),
10703            RerunStep::Rerun(vec!["8".to_owned()])
10704        );
10705    }
10706
10707    #[test]
10708    fn only_blocking_failures_are_candidates_for_a_rerun() {
10709        let check = |label: &str, verdict, required, run: &str| CheckInfo {
10710            label: label.to_owned(),
10711            verdict,
10712            required,
10713            run: Some(run.to_owned()),
10714        };
10715        let got = rerunnable(&[
10716            check("passed", Verdict::Pass, Some(true), "1"),
10717            check("optional", Verdict::Fail, Some(false), "2"),
10718            check("required", Verdict::Fail, Some(true), "3"),
10719            check("unknown", Verdict::Fail, None, "4"),
10720        ]);
10721        let labels: Vec<_> = got.iter().map(|(l, _)| l.as_str()).collect();
10722        assert_eq!(labels, ["required", "unknown"]);
10723    }
10724
10725    fn unrelated_ci_fixture() -> CiEvidence {
10726        let failure = CiFailure {
10727            path: "tests/web_render.rs".into(),
10728            start_line: 42,
10729            end_line: 42,
10730            title: "question_identity_disclosure_copy_and_live_updates".into(),
10731            message: "assertion failed".into(),
10732        };
10733        CiEvidence {
10734            head: "a".into(),
10735            base: "base-sha".into(),
10736            changed: vec![
10737                "AGENTS.md".into(),
10738                "src/talk.rs".into(),
10739                "tests/consult_investigation.rs".into(),
10740            ],
10741            checks: vec![CiCheckEvidence {
10742                label: "coverage".into(),
10743                head_check: 70,
10744                base_check: 60,
10745                app: 1,
10746                run: "7".into(),
10747                base_run: "6".into(),
10748                head_failures: vec![failure.clone()],
10749                base_failures: vec![failure],
10750                rerun_exhausted: true,
10751            }],
10752        }
10753    }
10754
10755    #[test]
10756    fn unrelated_ci_requires_complete_structural_evidence_for_every_failure() {
10757        let good = unrelated_ci_fixture();
10758        let red = vec!["coverage".into()];
10759        assert!(good.unrelated("a", &red));
10760        assert!(!good.unrelated("another-head", &red));
10761        assert!(!good.unrelated("a", &["coverage".into(), "test".into()]));
10762        assert!(!good.unrelated("a", &[]));
10763        for mutation in 0..8 {
10764            let mut bad = good.clone();
10765            match mutation {
10766                0 => bad.checks[0].rerun_exhausted = false,
10767                1 => bad.checks[0].base_failures.clear(),
10768                2 => bad.checks[0].head_failures.clear(),
10769                3 => bad.changed.push("tests/web_render.rs".into()),
10770                4 => bad.changed.push(".github/workflows/ci.yml".into()),
10771                5 => bad.checks[0].base_failures[0].message = "different failure".into(),
10772                6 => bad.checks.push(bad.checks[0].clone()),
10773                _ => bad.base.clear(),
10774            }
10775            assert!(!bad.unrelated("a", &red), "mutation {mutation}");
10776        }
10777    }
10778
10779    #[test]
10780    fn red_unknown_merge_state_waits_before_repair_but_never_implies_merge() {
10781        let pr = seen("a", Checks::Red, "UNKNOWN", false).pr;
10782        assert_eq!(decide(&pr, 0, 2, Duration::ZERO), Step::Wait);
10783        assert!(matches!(decide(&pr, 0, 2, CHECKS_GRACE), Step::Fix { .. }));
10784        assert!(matches!(
10785            decide(&pr, 2, 2, CHECKS_GRACE),
10786            Step::GiveUp { .. }
10787        ));
10788        let optional = CheckInfo {
10789            label: "coverage".into(),
10790            verdict: Verdict::Fail,
10791            required: Some(false),
10792            run: Some("7".into()),
10793        };
10794        assert!(rerunnable_with(std::slice::from_ref(&optional), false).is_empty());
10795        assert_eq!(
10796            rerunnable_with(&[optional], true),
10797            vec![("coverage".into(), Some("7".into()))]
10798        );
10799    }
10800
10801    #[tokio::test]
10802    async fn unrelated_ci_after_a_comment_round_does_not_charge_a_no_commit_stop() {
10803        let mut state = landing_state();
10804        let mut red = coverage_unknown();
10805        red.pr = seen("a", Checks::Red, "BLOCKED", true).pr;
10806        red.pr.failing = vec!["coverage".into()];
10807        let forge = Scripted::new(vec![red], vec![]);
10808        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10809        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10810        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10811        assert!(forge.calls().contains(&"fix"));
10812        assert!(state.land_unrelated.is_some());
10813        assert!(state.parked);
10814        assert!(
10815            !state
10816                .events
10817                .iter()
10818                .any(|e| e.message.contains("fixer produced no commit"))
10819        );
10820    }
10821
10822    fn coverage_unknown() -> Seen {
10823        let mut s = red("a", Some("7"));
10824        s.pr.blocking = Blocking::Unsaid;
10825        s.merge_state = "UNKNOWN".into();
10826        s.pr.failing = vec!["coverage".into()];
10827        s.contexts[0].label = "coverage".into();
10828        s.contexts[0].required = Some(false);
10829        s
10830    }
10831
10832    #[tokio::test]
10833    async fn run_196c_unknown_then_unstable_does_not_spend_a_fix_round() {
10834        let mut state = landing_state();
10835        let unknown = coverage_unknown();
10836        let mut unstable = unknown.clone();
10837        unstable.pr.blocking = Blocking::of("UNSTABLE");
10838        unstable.merge_state = "UNSTABLE".into();
10839        let forge = Scripted::new(vec![unknown, unstable], vec![(true, "")]);
10840        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10841        assert!(!forge.calls().contains(&"fix"));
10842        assert!(!forge.calls().contains(&"rerun"));
10843        assert_eq!(state.status, RunStatus::Merged);
10844        assert_eq!(state.pr.unwrap().round, 0);
10845    }
10846
10847    #[tokio::test]
10848    async fn run_196c_persistent_unknown_reruns_optional_once_then_parks_with_evidence() {
10849        let mut state = landing_state();
10850        let forge = Scripted::new(vec![coverage_unknown()], vec![]);
10851        *forge.evidence.lock().unwrap() = Some(unrelated_ci_fixture());
10852        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10853        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10854        assert!(!forge.calls().contains(&"fix"));
10855        assert!(state.parked);
10856        assert_eq!(state.status, RunStatus::Landing);
10857        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10858        let hold = state.land_unrelated.as_ref().unwrap();
10859        let q = ask::Questions::open().get(&hold.question).unwrap();
10860        assert!(
10861            q.actions
10862                .values()
10863                .any(|a| matches!(a, ask::ChoiceAction::Resume { .. }))
10864        );
10865        assert!(
10866            state
10867                .events
10868                .iter()
10869                .any(|e| e.message.contains("[ci-unrelated]") && e.message.contains("base-sha"))
10870        );
10871        let first = state.land_unsaid.clone();
10872        // A resume on this head neither extends UNKNOWN grace nor reruns 7.
10873        state.parked = false;
10874        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10875        assert_eq!(state.land_unsaid, first);
10876        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10877    }
10878
10879    fn red(head: &str, run: Option<&str>) -> Seen {
10880        let mut s = seen(head, Checks::Red, "BLOCKED", false);
10881        s.pr.failing = vec!["test (macos)".to_owned()];
10882        s.contexts = vec![CheckInfo {
10883            label: "test (macos)".to_owned(),
10884            verdict: Verdict::Fail,
10885            required: Some(true),
10886            run: run.map(str::to_owned),
10887        }];
10888        s
10889    }
10890
10891    const RERUN_PR: &str = "https://github.com/o/r/pull/42";
10892
10893    #[tokio::test]
10894    async fn a_flaky_red_check_is_rerun_without_a_fix_round_and_then_merges() {
10895        let mut state = landing_state();
10896        let forge = Scripted::new(
10897            vec![
10898                red("a", Some("7")),
10899                seen("a", Checks::Pending, "BLOCKED", false),
10900                seen("a", Checks::Green, "CLEAN", false),
10901            ],
10902            vec![(true, "")],
10903        );
10904        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10905        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10906        assert!(!forge.calls().contains(&"fix"));
10907        assert_eq!(state.status, RunStatus::Merged);
10908        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10909        let argvs = forge.argvs();
10910        assert_eq!(value_of(&argvs[0], "--match-head-commit"), Some("a"));
10911        assert!(
10912            state
10913                .events
10914                .iter()
10915                .any(|e| e.message.contains("test (macos)") && e.message.contains("run 7"))
10916        );
10917    }
10918
10919    #[tokio::test]
10920    async fn still_red_after_the_grace_goes_to_the_fixer_and_a_run_is_not_rerun_twice() {
10921        let mut state = landing_state();
10922        let forge = Scripted::new(
10923            vec![
10924                red("a", Some("7")),
10925                seen("a", Checks::Green, "CLEAN", false),
10926            ],
10927            vec![(true, "")],
10928        );
10929        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
10930        // The red view repeats for the grace, then the fixer is asked once.
10931        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10932        assert_eq!(forge.reruns.lock().unwrap().len(), 1);
10933        let calls = forge.calls();
10934        let rerun_at = calls.iter().position(|c| *c == "rerun").unwrap();
10935        let fix_at = calls.iter().position(|c| *c == "fix");
10936        assert!(fix_at.is_none_or(|f| f > rerun_at));
10937    }
10938
10939    #[tokio::test]
10940    async fn a_failed_run_is_rerun_while_another_run_is_still_pending() {
10941        let mut state = landing_state();
10942        let mut v = red("a", Some("7"));
10943        v.pr.checks = Checks::Pending;
10944        v.contexts.push(CheckInfo {
10945            label: "slow".to_owned(),
10946            verdict: Verdict::Pending,
10947            required: Some(true),
10948            run: Some("8".to_owned()),
10949        });
10950        let forge = Scripted::new(
10951            vec![v, seen("a", Checks::Green, "CLEAN", false)],
10952            vec![(true, "")],
10953        );
10954        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10955        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10956        assert_eq!(state.status, RunStatus::Merged);
10957    }
10958
10959    #[test]
10960    fn a_run_with_a_job_still_going_is_not_rerun_yet() {
10961        let mut v = red("a", Some("7"));
10962        v.contexts.push(CheckInfo {
10963            label: "same run, still going".to_owned(),
10964            verdict: Verdict::Pending,
10965            required: Some(true),
10966            run: Some("7".to_owned()),
10967        });
10968        assert!(rerunnable(&v.contexts).is_empty());
10969    }
10970
10971    #[tokio::test]
10972    async fn the_rerun_is_made_even_when_the_fix_budget_is_spent() {
10973        let mut state = landing_state();
10974        state.config.graph.land_rounds = 0;
10975        let forge = Scripted::new(
10976            vec![
10977                red("a", Some("7")),
10978                seen("a", Checks::Green, "CLEAN", false),
10979            ],
10980            vec![(true, "")],
10981        );
10982        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10983        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
10984        assert_eq!(state.status, RunStatus::Merged);
10985        assert!(!forge.calls().contains(&"fix"));
10986        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10987    }
10988
10989    #[tokio::test]
10990    async fn a_deferred_landing_fix_keeps_its_round_for_resume() {
10991        let mut state = landing_state();
10992        state.config.graph.land_rounds = 1;
10993        let forge = Scripted::new(vec![seen("a", Checks::Green, "CLEAN", true)], vec![]);
10994        *forge.fix.lock().unwrap() = Some(Fixed::Deferred);
10995        let pr = land_with(&mut state, RERUN_PR, &forge).await.unwrap();
10996        assert_eq!(pr.state, PrLifecycle::Open);
10997        assert_eq!(state.status, RunStatus::Blocked);
10998        assert_eq!(state.pr.as_ref().unwrap().round, 0);
10999        assert!(
11000            state.merge_decision.is_none(),
11001            "no ordinary failure handoff"
11002        );
11003        assert!(forge.argvs().is_empty(), "no merge on outage");
11004
11005        let recovered = Scripted::new(
11006            vec![
11007                seen("a", Checks::Green, "CLEAN", true),
11008                seen("b", Checks::Green, "CLEAN", false),
11009            ],
11010            vec![(true, "")],
11011        );
11012        *recovered.fix.lock().unwrap() = Some(Fixed::Committed { head: "b".into() });
11013        land_with(&mut state, RERUN_PR, &recovered).await.unwrap();
11014        assert_eq!(state.status, RunStatus::Merged);
11015        assert_eq!(state.pr.as_ref().unwrap().round, 1);
11016        assert_eq!(recovered.calls().iter().filter(|c| **c == "fix").count(), 1);
11017    }
11018
11019    #[tokio::test]
11020    async fn the_last_fix_rounds_new_head_gets_its_rerun() {
11021        let mut state = landing_state();
11022        state.config.graph.land_rounds = 1;
11023        let forge = Scripted::new(
11024            vec![
11025                seen("a", Checks::Green, "CLEAN", true),
11026                red("b", Some("7")),
11027                seen("b", Checks::Green, "CLEAN", false),
11028            ],
11029            vec![(true, "")],
11030        );
11031        *forge.fix.lock().unwrap() = Some(Fixed::Committed {
11032            head: "b".to_owned(),
11033        });
11034        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11035        assert_eq!(*forge.reruns.lock().unwrap(), ["7"]);
11036        assert_eq!(forge.calls().iter().filter(|c| **c == "fix").count(), 1);
11037        assert_eq!(state.pr.as_ref().unwrap().round, 1);
11038        assert_eq!(state.status, RunStatus::Merged);
11039        assert_eq!(state.land_reruns.as_ref().unwrap().head, "b");
11040        assert_eq!(
11041            value_of(&forge.argvs()[0], "--match-head-commit"),
11042            Some("b")
11043        );
11044    }
11045
11046    #[tokio::test]
11047    async fn a_failed_rerun_call_falls_through_to_the_fix_in_the_same_lap() {
11048        let mut state = landing_state();
11049        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11050        *forge.rerun_fails.lock().unwrap() = true;
11051        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11052        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11053        assert_eq!(forge.reruns.lock().unwrap().len(), 1, "never retried");
11054        assert_eq!(
11055            forge.calls().iter().filter(|c| **c == "fix").count(),
11056            1,
11057            "{:?}",
11058            forge.calls()
11059        );
11060    }
11061
11062    #[tokio::test]
11063    async fn a_saved_rerun_record_stops_a_restart_from_rerunning() {
11064        let mut state = landing_state();
11065        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11066        // The record was written just before the previous pass died.
11067        state.land_reruns = Some(reran("a", &[("7", *forge.clock.lock().unwrap())]));
11068        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11069        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11070        assert!(forge.reruns.lock().unwrap().is_empty());
11071    }
11072
11073    #[tokio::test]
11074    async fn the_switch_off_and_a_missing_run_id_go_straight_to_the_fixer() {
11075        let mut state = landing_state();
11076        state.config.graph.rerun_flaky_checks = false;
11077        let forge = Scripted::new(vec![red("a", Some("7"))], vec![]);
11078        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11079        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11080        assert!(forge.reruns.lock().unwrap().is_empty());
11081        assert!(forge.calls().contains(&"fix"));
11082
11083        let mut state = landing_state();
11084        let forge = Scripted::new(vec![red("a", None)], vec![]);
11085        *forge.fix.lock().unwrap() = Some(Fixed::Declined);
11086        land_with(&mut state, RERUN_PR, &forge).await.unwrap();
11087        assert!(forge.reruns.lock().unwrap().is_empty());
11088        assert!(forge.calls().contains(&"fix"));
11089    }
11090
11091    fn names(xs: &[&str]) -> BTreeSet<String> {
11092        xs.iter().map(|x| (*x).to_owned()).collect()
11093    }
11094
11095    #[test]
11096    fn a_required_check_the_rollup_never_listed_is_named() {
11097        let req = names(&["build"]);
11098        let why = waiting_on("BLOCKED", &[passing("review")], Some(&req));
11099        assert!(why.contains("never reported: build"), "{why}");
11100        assert!(!why.contains("probably waiting for a review"), "{why}");
11101    }
11102
11103    #[test]
11104    fn an_unreadable_required_list_is_not_read_as_a_review_wait() {
11105        let why = waiting_on("BLOCKED", &[passing("review")], None);
11106        assert!(why.contains("could not be read"), "{why}");
11107        assert!(!why.contains("probably waiting for a review"), "{why}");
11108    }
11109
11110    #[test]
11111    fn all_required_reported_keeps_the_review_guess() {
11112        let req = names(&["build"]);
11113        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
11114        assert!(why.contains("probably waiting for a review"), "{why}");
11115        assert!(!why.contains("never reported"), "{why}");
11116    }
11117
11118    #[test]
11119    fn required_names_match_the_rollup_ignoring_case_only() {
11120        let req = names(&["Build"]);
11121        let why = waiting_on("BLOCKED", &[passing("build")], Some(&req));
11122        assert!(!why.contains("never reported"), "{why}");
11123    }
11124
11125    #[test]
11126    fn required_contexts_are_read_from_protection_and_rulesets() {
11127        let classic = r#"{"contexts":["build"],"checks":[{"context":"lint","app_id":1}]}"#;
11128        assert_eq!(
11129            parse_classic_required(classic),
11130            Some(names(&["build", "lint"]))
11131        );
11132        let rules = r#"[{"type":"pull_request","parameters":{}},
11133            {"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"test"}]}}]"#;
11134        assert_eq!(parse_ruleset_required(rules), Some(names(&["test"])));
11135        assert_eq!(parse_ruleset_required("nope"), None);
11136        assert_eq!(encode_path_segment("release/1.x"), "release%2F1.x");
11137    }
11138
11139    #[test]
11140    fn the_direct_merge_guard_needs_the_approved_head_bound_to_its_checks() {
11141        let shown = BTreeSet::new();
11142        let ok = seen("a", Checks::Green, "CLEAN", false);
11143        let guard = |s: Option<&Seen>| {
11144            direct_merge_is_safe(
11145                s,
11146                "A",
11147                &shown,
11148                0,
11149                4,
11150                Duration::ZERO,
11151                CiExpectation::Expected,
11152            )
11153        };
11154        assert!(guard(Some(&ok)));
11155        assert!(!guard(None));
11156        assert!(!guard(Some(&seen("b", Checks::Green, "CLEAN", false))));
11157        let mut stale = ok.clone();
11158        stale.rollup_head = "old".to_owned();
11159        assert!(!guard(Some(&stale)));
11160        assert!(!guard(Some(&seen("a", Checks::Pending, "BLOCKED", false))));
11161        assert!(!guard(Some(&merged_view("a"))));
11162    }
11163
11164    #[test]
11165    fn the_rollup_node_carries_whether_each_check_is_required() {
11166        let node = node_json("new", "SUCCESS", false)
11167            .replace(r#""name":"ci","#, r#""name":"ci","isRequired":true,"#);
11168        let s = seen_from(&view_json("new"), Some(&node)).unwrap();
11169        assert_eq!(s.contexts.len(), 1);
11170        assert_eq!(s.contexts[0].required, Some(true));
11171        let s = seen_from(&view_json("new"), Some(&node_json("new", "SUCCESS", false))).unwrap();
11172        assert_eq!(s.contexts[0].required, None);
11173    }
11174
11175    #[tokio::test]
11176    async fn a_resume_that_cannot_disable_a_recorded_arm_stops_and_keeps_the_record() {
11177        let mut state = landing_state();
11178        state.land_armed_head = Some("a".to_owned());
11179        let forge = Scripted::new(
11180            vec![seen("a", Checks::Green, "CLEAN", true)],
11181            vec![(false, "disable exploded")],
11182        );
11183        land_with(&mut state, URL, &forge).await.unwrap();
11184        assert!(!forge.calls().contains(&"fix"));
11185        assert_eq!(state.status, RunStatus::Blocked);
11186        assert_eq!(state.land_armed_head.as_deref(), Some("a"));
11187        let why = state.merge.as_ref().unwrap().detail.clone();
11188        assert!(why.contains("disable exploded"), "{why}");
11189    }
11190}